diff --git a/packages/core/squads/stores/index.ts b/packages/core/squads/stores/index.ts index 6a7bb4921..b06cb8a26 100644 --- a/packages/core/squads/stores/index.ts +++ b/packages/core/squads/stores/index.ts @@ -1,5 +1,11 @@ export { useSquadsViewStore, + SQUAD_SCOPES, + SQUAD_SORT_DEFAULT_DIRECTION, + SQUAD_DEFAULT_HIDDEN_COLUMNS, type SquadsScope, type SquadsViewState, + type SquadSortField, + type SquadSortDirection, + type SquadColumnKey, } from "./view-store"; diff --git a/packages/core/squads/stores/view-store.test.ts b/packages/core/squads/stores/view-store.test.ts index ca2c8dabc..4727d8d79 100644 --- a/packages/core/squads/stores/view-store.test.ts +++ b/packages/core/squads/stores/view-store.test.ts @@ -44,7 +44,7 @@ describe("useSquadsViewStore", () => { expect(useSquadsViewStore.getState().scope).toBe("all"); }); - it("partialize persists only scope under the workspace-namespaced key", async () => { + it("partialize persists view prefs (no actions) under the workspace-namespaced key", async () => { setCurrentWorkspace("acme", "ws_a"); await flush(); useSquadsViewStore.getState().setScope("all"); @@ -52,7 +52,13 @@ describe("useSquadsViewStore", () => { const raw = localStorage.getItem("multica_squads_view:acme"); expect(raw).not.toBeNull(); const parsed = JSON.parse(raw as string); - expect(parsed.state).toEqual({ scope: "all" }); + expect(Object.keys(parsed.state).sort()).toEqual([ + "hiddenColumns", + "scope", + "sortDirection", + "sortField", + ]); + expect(parsed.state.scope).toBe("all"); }); it("rehydrates a different saved scope on workspace switch", async () => { diff --git a/packages/core/squads/stores/view-store.ts b/packages/core/squads/stores/view-store.ts index 2e21bfc35..d82ce426a 100644 --- a/packages/core/squads/stores/view-store.ts +++ b/packages/core/squads/stores/view-store.ts @@ -8,29 +8,111 @@ import { } from "../../platform/workspace-storage"; import { defaultStorage } from "../../platform/storage"; +// View preferences for the squads list page: scope, sort, column visibility. +// Persisted per workspace, per user/device. No filters (the set is tiny); +// no search (scope-bearing list). Mirrors the agents/skills view stores. + +// Scope is the ownership lens (creator-based). No "archived" scope: the +// list endpoint hard-filters archived squads and there is no restore +// endpoint, so archived squads can't be surfaced or managed. export type SquadsScope = "mine" | "all"; +export const SQUAD_SCOPES: SquadsScope[] = ["mine", "all"]; + +export type SquadSortField = "name" | "members" | "created"; + +export type SquadSortDirection = "asc" | "desc"; + +/** Per-field direction applied when the user switches TO that field. */ +export const SQUAD_SORT_DEFAULT_DIRECTION: Record< + SquadSortField, + SquadSortDirection +> = { + name: "asc", + members: "desc", + created: "desc", +}; + +// User-hideable columns. Name and leader (the squad's defining relationship) +// are always visible. +export type SquadColumnKey = "members" | "creator" | "created"; + +/** Creator and created are opt-in: hidden until the user enables them. */ +export const SQUAD_DEFAULT_HIDDEN_COLUMNS: SquadColumnKey[] = [ + "creator", + "created", +]; + export interface SquadsViewState { scope: SquadsScope; + sortField: SquadSortField; + sortDirection: SquadSortDirection; + hiddenColumns: SquadColumnKey[]; setScope: (scope: SquadsScope) => void; + /** Header click: toggles direction on the active field, otherwise switches + * to the field with its default direction. */ + toggleSort: (field: SquadSortField) => void; + /** Display panel select: switches field (default direction), no toggle. */ + setSortField: (field: SquadSortField) => void; + setSortDirection: (direction: SquadSortDirection) => void; + toggleColumn: (key: SquadColumnKey) => void; } +const DEFAULTS = { + scope: "mine" as SquadsScope, + sortField: "name" as SquadSortField, + sortDirection: SQUAD_SORT_DEFAULT_DIRECTION.name, + hiddenColumns: SQUAD_DEFAULT_HIDDEN_COLUMNS, +}; + export const useSquadsViewStore = create()( persist( (set) => ({ - scope: "mine", + ...DEFAULTS, setScope: (scope) => set({ scope }), + toggleSort: (field) => + set((state) => + state.sortField === field + ? { + sortDirection: state.sortDirection === "asc" ? "desc" : "asc", + } + : { + sortField: field, + sortDirection: SQUAD_SORT_DEFAULT_DIRECTION[field], + }, + ), + setSortField: (field) => + set((state) => + state.sortField === field + ? {} + : { + sortField: field, + sortDirection: SQUAD_SORT_DEFAULT_DIRECTION[field], + }, + ), + setSortDirection: (direction) => set({ sortDirection: direction }), + toggleColumn: (key) => + set((state) => ({ + hiddenColumns: state.hiddenColumns.includes(key) + ? state.hiddenColumns.filter((k) => k !== key) + : [...state.hiddenColumns, key], + })), }), { name: "multica_squads_view", - storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)), - partialize: (state) => ({ scope: state.scope }), + storage: createJSONStorage(() => + createWorkspaceAwareStorage(defaultStorage), + ), + partialize: (state) => ({ + scope: state.scope, + sortField: state.sortField, + sortDirection: state.sortDirection, + hiddenColumns: state.hiddenColumns, + }), // On rehydrate, if the new workspace has no persisted value, reset to - // the default "mine" instead of leaving the previous workspace's in- - // memory scope in place. Default merge keeps current state when - // persisted is undefined, which would leak "all" across workspaces. + // the defaults instead of leaking the previous workspace's state. merge: (persisted, current) => { - if (!persisted) return { ...current, scope: "mine" }; + if (!persisted) return { ...current, ...DEFAULTS }; return { ...current, ...(persisted as Partial) }; }, }, diff --git a/packages/views/locales/en/squads.json b/packages/views/locales/en/squads.json index ec5f63ab0..41d7bd65f 100644 --- a/packages/views/locales/en/squads.json +++ b/packages/views/locales/en/squads.json @@ -3,7 +3,17 @@ "title": "Squads", "new_button": "New Squad", "empty_no_squads": "No squads yet. Create one to get started.", - "empty_no_match": "No squads match your filters." + "empty_no_match": "No squads match your filters.", + "table": { + "name": "Squad", + "leader": "Leader", + "members": "Members", + "creator": "Created by", + "created": "Created" + }, + "no_matches": "No squads match", + "row_menu": "Squad actions", + "archive_action": "Archive" }, "inspector": { "details_section": "Details", @@ -72,5 +82,16 @@ "description": "Squad instructions are injected into the leader agent's prompt whenever it works on an issue assigned to this squad. Use them to give the leader squad-wide guidance, working agreements, or context the leader should follow on every task.", "unsaved_changes": "Unsaved changes", "save_button": "Save" + }, + "scope": { + "mine": "Mine", + "all": "All" + }, + "toolbar": { + "display": "Display", + "sort_by": "Sort by", + "direction_asc": "Ascending", + "direction_desc": "Descending", + "section_columns": "Columns" } } diff --git a/packages/views/locales/ja/squads.json b/packages/views/locales/ja/squads.json index af578a672..2012a29a8 100644 --- a/packages/views/locales/ja/squads.json +++ b/packages/views/locales/ja/squads.json @@ -3,7 +3,17 @@ "title": "スクワッド", "new_button": "新規スクワッド", "empty_no_squads": "スクワッドはまだありません。作成して始めましょう。", - "empty_no_match": "フィルターに一致するスクワッドはありません。" + "empty_no_match": "フィルターに一致するスクワッドはありません。", + "table": { + "name": "スカッド", + "leader": "リーダー", + "members": "メンバー", + "creator": "作成者", + "created": "作成日時" + }, + "no_matches": "該当するスカッドはありません", + "row_menu": "スカッドの操作", + "archive_action": "アーカイブ" }, "inspector": { "details_section": "詳細", @@ -69,5 +79,16 @@ "description": "スクワッドの指示は、リーダーエージェントがこのスクワッドに割り当てられたイシューに取り組むたびに、そのプロンプトに挿入されます。スクワッド全体の方針、作業上の取り決め、リーダーがすべてのタスクで従うべきコンテキストを伝えるために使用します。", "unsaved_changes": "保存していない変更", "save_button": "保存" + }, + "scope": { + "mine": "自分", + "all": "すべて" + }, + "toolbar": { + "display": "表示", + "sort_by": "並び替え", + "direction_asc": "昇順", + "direction_desc": "降順", + "section_columns": "列" } } diff --git a/packages/views/locales/ko/squads.json b/packages/views/locales/ko/squads.json index cf2bbce8d..effa556ac 100644 --- a/packages/views/locales/ko/squads.json +++ b/packages/views/locales/ko/squads.json @@ -3,7 +3,17 @@ "title": "스쿼드", "new_button": "새 스쿼드", "empty_no_squads": "아직 스쿼드가 없습니다. 하나 만들어 시작하세요.", - "empty_no_match": "필터와 일치하는 스쿼드가 없습니다." + "empty_no_match": "필터와 일치하는 스쿼드가 없습니다.", + "table": { + "name": "스쿼드", + "leader": "리더", + "members": "멤버", + "creator": "생성자", + "created": "생성일" + }, + "no_matches": "일치하는 스쿼드가 없습니다", + "row_menu": "스쿼드 작업", + "archive_action": "보관" }, "inspector": { "details_section": "세부 정보", @@ -72,5 +82,16 @@ "description": "스쿼드에 할당된 이슈를 리더 에이전트가 처리할 때마다 스쿼드 지침이 리더 프롬프트에 포함됩니다. 스쿼드 전체의 방향, 작업 합의, 매 작업마다 따라야 할 맥락을 리더에게 전달할 때 사용하세요.", "unsaved_changes": "저장하지 않은 변경사항", "save_button": "저장" + }, + "scope": { + "mine": "내 스쿼드", + "all": "전체" + }, + "toolbar": { + "display": "표시", + "sort_by": "정렬", + "direction_asc": "오름차순", + "direction_desc": "내림차순", + "section_columns": "열" } } diff --git a/packages/views/locales/zh-Hans/squads.json b/packages/views/locales/zh-Hans/squads.json index d19616e5d..dd0cd3c04 100644 --- a/packages/views/locales/zh-Hans/squads.json +++ b/packages/views/locales/zh-Hans/squads.json @@ -3,7 +3,17 @@ "title": "小队", "new_button": "新建小队", "empty_no_squads": "还没有小队,创建一个开始吧。", - "empty_no_match": "没有匹配筛选条件的小队。" + "empty_no_match": "没有匹配筛选条件的小队。", + "table": { + "name": "小队", + "leader": "队长", + "members": "成员", + "creator": "创建者", + "created": "创建时间" + }, + "no_matches": "没有匹配的小队", + "row_menu": "小队操作", + "archive_action": "归档" }, "inspector": { "details_section": "详情", @@ -72,5 +82,16 @@ "description": "小队指引会在 Leader 智能体处理分配给该小队的 issue 时注入到它的 prompt 中。可用来给 Leader 提供贯穿全队的指导、协作规范,或每次任务都应遵循的上下文。", "unsaved_changes": "有未保存的修改", "save_button": "保存" + }, + "scope": { + "mine": "我的", + "all": "全部" + }, + "toolbar": { + "display": "显示", + "sort_by": "排序", + "direction_asc": "升序", + "direction_desc": "降序", + "section_columns": "列" } } diff --git a/packages/views/squads/components/squads-page.tsx b/packages/views/squads/components/squads-page.tsx index 76ce3a1e4..295bf4e8d 100644 --- a/packages/views/squads/components/squads-page.tsx +++ b/packages/views/squads/components/squads-page.tsx @@ -1,214 +1,130 @@ "use client"; import { useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { + ArrowDown, + ArrowUp, + ChevronDown, + Loader2, + MoreHorizontal, + Plus, + Trash2, + Users, +} from "lucide-react"; +import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; -import { agentListOptions, memberListOptions, squadListOptions } from "@multica/core/workspace/queries"; +import { + agentListOptions, + memberListOptions, + squadListOptions, + workspaceKeys, +} from "@multica/core/workspace/queries"; import { resolvePublicFileUrl } from "@multica/core/workspace/avatar-url"; import { useAuthStore } from "@multica/core/auth"; -import { useSquadsViewStore } from "@multica/core/squads/stores"; +import { api } from "@multica/core/api"; +import { useModalStore } from "@multica/core/modals"; +import { + useSquadsViewStore, + SQUAD_SCOPES, + SQUAD_DEFAULT_HIDDEN_COLUMNS, + type SquadColumnKey, + type SquadsScope, + type SquadSortField, +} from "@multica/core/squads/stores"; +import type { Agent, MemberWithUser, Squad } from "@multica/core/types"; +import { Button } from "@multica/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@multica/ui/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@multica/ui/components/ui/dropdown-menu"; +import { + ListGrid, + ListGridCell, + ListGridHeader, + ListGridHeaderCell, + ListGridRow, + type ListGridSortDirection, +} from "@multica/ui/components/ui/list-grid"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@multica/ui/components/ui/popover"; +import { Skeleton } from "@multica/ui/components/ui/skeleton"; +import { Switch } from "@multica/ui/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@multica/ui/components/ui/tooltip"; +import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar"; +import { ActorAvatar } from "../../common/actor-avatar"; import { AppLink } from "../../navigation"; import { PageHeader } from "../../layout/page-header"; -import { Users, Plus, Search, Bot, User } from "lucide-react"; -import { Button } from "@multica/ui/components/ui/button"; -import { Input } from "@multica/ui/components/ui/input"; -import { Skeleton } from "@multica/ui/components/ui/skeleton"; -import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar"; -import { useModalStore } from "@multica/core/modals"; -import type { Agent, Squad } from "@multica/core/types"; import { useT } from "../../i18n"; -import { matchesPinyin } from "../../editor/extensions/pinyin-match"; -type Scope = "mine" | "all"; +// Column template — the simplest member of the ListGrid family (squads are +// the fewest entity, 1-5 rows): subgrid template + var tracks + two-zone +// responsiveness + single scroll container, but NO virtualization, checkbox, +// or batch. Identity two-line rows (avatar + name + description, 64px) like +// the agents list. Name + leader are the core set (<@2xl); members / creator +// / created are @2xl. The kebab track collapses when the viewer can't manage +// any squad (workspace admin only). +const GRID_COLS = + "grid-cols-[0.75rem_minmax(120px,1fr)_var(--sqc-leader)_var(--sqc-kebab)_0.75rem] " + + "@2xl:grid-cols-[0.75rem_minmax(200px,1fr)_var(--sqc-leader)_var(--sqc-members)_var(--sqc-creator)_var(--sqc-created)_var(--sqc-kebab)_0.75rem]"; -export function SquadsPage() { - const { t } = useT("squads"); - const workspace = useCurrentWorkspace(); - const wsId = workspace?.id ?? ""; - const p = useWorkspacePaths(); - const currentUser = useAuthStore((s) => s.user); - const { data: squads = [], isLoading } = useQuery({ - ...squadListOptions(wsId), - enabled: !!wsId, - }); - const { data: agents = [] } = useQuery(agentListOptions(wsId)); - const { data: members = [] } = useQuery(memberListOptions(wsId)); +const LEADER_WIDTH = 160; +const COLUMN_WIDTHS: Record = { + members: 120, + creator: 144, + created: 104, +}; - const agentsById = useMemo(() => { - const m = new Map(); - for (const a of agents) m.set(a.id, a); - return m; - }, [agents]); +// Fixed tracks (edges 12+12, name min 200, leader 160) plus the 7 gap-x-3 +// gaps between the wide template's 8 tracks (zero-width tracks still carry +// gaps). +const FIXED_TRACKS_WIDTH = 224 + LEADER_WIDTH + 7 * 12; - const membersByUserId = useMemo(() => { - const m = new Map(); - for (const mem of members) m.set(mem.user_id, { name: mem.name, avatar_url: mem.avatar_url }); - return m; - }, [members]); - - const scope = useSquadsViewStore((s) => s.scope); - const setScope = useSquadsViewStore((s) => s.setScope); - const [search, setSearch] = useState(""); - - const scopeCounts = useMemo(() => { - let mine = 0; - if (currentUser) { - for (const s of squads) mine += s.creator_id === currentUser.id ? 1 : 0; - } - return { all: squads.length, mine }; - }, [squads, currentUser]); - - const filtered = useMemo(() => { - const q = search.trim().toLowerCase(); - return squads.filter((s) => { - if (scope === "mine" && currentUser && s.creator_id !== currentUser.id) return false; - if (q && !s.name.toLowerCase().includes(q) && !matchesPinyin(s.name, q) && !s.description.toLowerCase().includes(q)) return false; - return true; - }); - }, [squads, scope, currentUser, search]); - - return ( -
- -
- -

{t(($) => $.page.title)}

- {!isLoading && squads.length > 0 && ( - {squads.length} - )} -
- -
- -
- {isLoading ? ( - - ) : squads.length === 0 ? ( -
- -

{t(($) => $.page.empty_no_squads)}

-
- ) : ( - <> -
-
- - setSearch(e.target.value)} - placeholder="Search squads..." - className="h-8 w-full pl-8 text-sm" - /> -
- - - {filtered.length} / {squads.length} - -
- - {filtered.length === 0 ? ( -
-

{t(($) => $.page.empty_no_match)}

-
- ) : ( -
-
- {filtered.map((squad) => ( - - ))} -
-
- )} - - )} -
-
- ); +function columnTrackVars( + isVisible: (key: SquadColumnKey) => boolean, + showActions: boolean, +): React.CSSProperties { + const width = (key: SquadColumnKey) => + isVisible(key) ? `${COLUMN_WIDTHS[key]}px` : "0px"; + const minWidth = + FIXED_TRACKS_WIDTH + + (Object.keys(COLUMN_WIDTHS) as SquadColumnKey[]).reduce( + (sum, key) => sum + (isVisible(key) ? COLUMN_WIDTHS[key] : 0), + 0, + ) + + (showActions ? 28 : 0); + return { + "--sqc-leader": `${LEADER_WIDTH}px`, + "--sqc-members": width("members"), + "--sqc-creator": width("creator"), + "--sqc-created": width("created"), + "--sqc-kebab": showActions ? "1.75rem" : "0px", + "--sqc-minw": `${minWidth}px`, + } as React.CSSProperties; } -function SquadCard({ squad, leader, creator, href }: { squad: Squad; leader?: Agent; creator?: { name: string; avatar_url: string | null }; href: string }) { - return ( - - -
-

{squad.name}

- {squad.description && ( -

{squad.description}

- )} -
- {leader && ( -
- - {leader.name} -
- )} - {creator && ( -
- - {creator.name} -
- )} -
-
-
- ); -} - -function ScopeSegment({ scope, setScope, counts }: { scope: Scope; setScope: (v: Scope) => void; counts: { all: number; mine: number } }) { - return ( -
- setScope("mine")} /> - setScope("all")} /> -
- ); -} - -function ScopeButton({ active, label, count, onClick }: { active: boolean; label: string; count: number; onClick: () => void }) { - return ( - - ); -} - -function SquadsListSkeleton() { - return ( - <> -
- - -
-
-
- {Array.from({ length: 4 }).map((_, i) => ( -
- -
- - -
-
- ))} -
-
- - ); -} +// --------------------------------------------------------------------------- +// Cells +// --------------------------------------------------------------------------- function SquadAvatar({ squad }: { squad: Squad }) { const initials = squad.name @@ -223,17 +139,713 @@ function SquadAvatar({ squad }: { squad: Squad }) { name={squad.name} initials={initials} avatarUrl={resolvePublicFileUrl(squad.avatar_url)} - size={36} - className="rounded-md" + size={32} + className="shrink-0 rounded-md" /> ); } return (
); } + +// Two-line identity cell — same form as the agents list. +function NameCell({ squad }: { squad: Squad }) { + return ( + + +
+ + {squad.name} + + {squad.description ? ( + + {squad.description} + + ) : null} +
+
+ ); +} + +function LeaderCell({ + leaderId, + leader, +}: { + leaderId: string; + leader: Agent | undefined; +}) { + return ( + + + + {leader?.name ?? leaderId.slice(0, 8)} + + + ); +} + +// Polymorphic member avatar stack (agent + human members), driven by the +// list payload's member_preview / member_count. NOT AgentAvatarStack, which +// is agent-only. +function MembersCell({ squad }: { squad: Squad }) { + const preview = squad.member_preview ?? []; + const count = squad.member_count ?? preview.length; + if (count === 0) { + return ( + + + + ); + } + const visible = preview.slice(0, 3); + const overflow = count - visible.length; + return ( + +
+ {visible.map((m) => ( + + + + ))} + {overflow > 0 && ( + + +{overflow} + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Archive (= delete) dialog — reuses the existing archive_dialog copy. +// Workspace owner/admin only (backend gate). No restore endpoint exists, so +// once archived a squad is gone from the UI. +// --------------------------------------------------------------------------- + +function ArchiveSquadDialog({ + squad, + open, + onOpenChange, +}: { + squad: Squad; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useT("squads"); + const wsId = useCurrentWorkspace()?.id ?? ""; + const qc = useQueryClient(); + const archive = useMutation({ + mutationFn: () => api.deleteSquad(squad.id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) }); + onOpenChange(false); + toast.success(t(($) => $.archive_dialog.title)); + }, + onError: (err) => + toast.error(err instanceof Error ? err.message : String(err)), + }); + return ( + + + + {t(($) => $.archive_dialog.title)} + + {t(($) => $.archive_dialog.description, { name: squad.name })} + + + + + + + + + ); +} + +function SquadRowActions({ squad }: { squad: Squad }) { + const { t } = useT("squads"); + const [archiveOpen, setArchiveOpen] = useState(false); + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + className="flex items-center" + > + + $.page.row_menu)} + className="flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/row:opacity-100 data-popup-open:bg-accent data-popup-open:opacity-100 data-popup-open:text-accent-foreground" + > + + + } + /> + + setArchiveOpen(true)} + > + + {t(($) => $.page.archive_action)} + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Header + toolbar +// --------------------------------------------------------------------------- + +function SquadListHeader({ + sortField, + sortDirection, + onSort, + isColVisible, +}: { + sortField: SquadSortField; + sortDirection: ListGridSortDirection; + onSort: (field: SquadSortField) => void; + isColVisible: (key: SquadColumnKey) => boolean; +}) { + const { t } = useT("squads"); + const sorted = (field: SquadSortField) => + sortField === field ? sortDirection : false; + return ( + + onSort("name")}> + {t(($) => $.page.table.name)} + + {t(($) => $.page.table.leader)} + {isColVisible("members") ? ( + onSort("members")} + > + {t(($) => $.page.table.members)} + + ) : ( + + )} + {isColVisible("creator") ? ( + + {t(($) => $.page.table.creator)} + + ) : ( + + )} + {isColVisible("created") ? ( + onSort("created")} + > + {t(($) => $.page.table.created)} + + ) : ( + + )} + {/* kebab track placeholder (track width collapses when no actions) */} + + ); +} + +const COLUMN_KEYS: SquadColumnKey[] = ["members", "creator", "created"]; +const SORT_FIELDS: SquadSortField[] = ["name", "members", "created"]; + +function SquadListToolbar({ + scope, + onScopeChange, + scopeCounts, + sortField, + sortDirection, + onSortFieldChange, + onSortDirectionChange, + hiddenColumns, + onToggleColumn, +}: { + scope: SquadsScope; + onScopeChange: (scope: SquadsScope) => void; + scopeCounts: Record; + sortField: SquadSortField; + sortDirection: ListGridSortDirection; + onSortFieldChange: (field: SquadSortField) => void; + onSortDirectionChange: (direction: ListGridSortDirection) => void; + hiddenColumns: SquadColumnKey[]; + onToggleColumn: (key: SquadColumnKey) => void; +}) { + const { t } = useT("squads"); + const SCOPE_LABELS: Record = { + mine: t(($) => $.scope.mine), + all: t(($) => $.scope.all), + }; + const SORT_LABELS: Record = { + name: t(($) => $.page.table.name), + members: t(($) => $.page.table.members), + created: t(($) => $.page.table.created), + }; + const COLUMN_LABELS: Record = { + members: t(($) => $.page.table.members), + creator: t(($) => $.page.table.creator), + created: t(($) => $.page.table.created), + }; + const sortLabel = SORT_LABELS[sortField]; + + return ( +
+
+
+ {SQUAD_SCOPES.map((s) => ( + + ))} +
+ + + {SCOPE_LABELS[scope]} + + + } + /> + + onScopeChange(value as SquadsScope)} + > + {SQUAD_SCOPES.map((s) => ( + + {SCOPE_LABELS[s]} + + {scopeCounts[s]} + + + ))} + + + +
+ + {/* Display settings */} + + + + {sortDirection === "asc" ? ( + + ) : ( + + )} + {sortLabel} + + } + /> + } + /> + + {t(($) => $.toolbar.display)} + + + +
+ + {t(($) => $.toolbar.sort_by)} + +
+ + + {sortLabel} + + + } + /> + + + onSortFieldChange(v as SquadSortField) + } + > + {SORT_FIELDS.map((field) => ( + + {SORT_LABELS[field]} + + ))} + + + + +
+
+
+ + {t(($) => $.toolbar.section_columns)} + +
+ {COLUMN_KEYS.map((key) => ( + + ))} +
+
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export function SquadsPage() { + const { t } = useT("squads"); + const workspace = useCurrentWorkspace(); + const wsId = workspace?.id ?? ""; + const p = useWorkspacePaths(); + const currentUser = useAuthStore((s) => s.user); + + const { data: squads = [], isLoading } = useQuery({ + ...squadListOptions(wsId), + enabled: !!wsId, + }); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + + const agentsById = useMemo(() => { + const m = new Map(); + for (const a of agents) m.set(a.id, a); + return m; + }, [agents]); + + const isWorkspaceAdmin = useMemo(() => { + if (!currentUser) return false; + const me = members.find((mem: MemberWithUser) => mem.user_id === currentUser.id); + return me?.role === "owner" || me?.role === "admin"; + }, [members, currentUser]); + + const scope = useSquadsViewStore((s) => s.scope); + const setScope = useSquadsViewStore((s) => s.setScope); + const sortField = useSquadsViewStore((s) => s.sortField); + const sortDirection = useSquadsViewStore((s) => s.sortDirection); + const hiddenColumns = useSquadsViewStore((s) => s.hiddenColumns); + const handleSort = useSquadsViewStore((s) => s.toggleSort); + const handleSortFieldSelect = useSquadsViewStore((s) => s.setSortField); + const setSortDirection = useSquadsViewStore((s) => s.setSortDirection); + const toggleColumn = useSquadsViewStore((s) => s.toggleColumn); + + const isColVisible = (key: SquadColumnKey) => !hiddenColumns.includes(key); + + const scopeCounts = useMemo>(() => { + let mine = 0; + if (currentUser) { + for (const s of squads) if (s.creator_id === currentUser.id) mine++; + } + return { mine, all: squads.length }; + }, [squads, currentUser]); + + const rows = useMemo(() => { + const inScope = squads.filter((s) => { + if (scope === "mine") { + return !!currentUser && s.creator_id === currentUser.id; + } + return true; + }); + const dir = sortDirection === "asc" ? 1 : -1; + const sorted = [...inScope]; + sorted.sort((a, b) => { + if (sortField === "members") { + const av = a.member_count ?? a.member_preview?.length ?? 0; + const bv = b.member_count ?? b.member_preview?.length ?? 0; + return (av - bv) * dir || a.name.localeCompare(b.name); + } + if (sortField === "created") { + return ( + (Date.parse(a.created_at) - Date.parse(b.created_at)) * dir + ); + } + return a.name.localeCompare(b.name) * dir; + }); + return sorted; + }, [squads, scope, currentUser, sortField, sortDirection]); + + return ( +
+ +
+ +

{t(($) => $.page.title)}

+ {squads.length > 0 && ( + + {squads.length} + + )} +
+ {/* Quiet chrome button (outline, icon-only below md) — primary is + reserved for the empty state. */} + +
+ + {isLoading ? ( + + ) : squads.length === 0 ? ( +
+ +

+ {t(($) => $.page.empty_no_squads)} +

+ +
+ ) : ( + <> + +
+ + + {rows.length === 0 ? ( +
+ {t(($) => $.page.no_matches)} +
+ ) : ( + rows.map((squad) => ( + } + > + + + {isColVisible("members") ? ( + + ) : ( + + )} + {isColVisible("creator") ? ( + + + + ) : ( + + )} + {isColVisible("created") ? ( + + {new Date(squad.created_at).toLocaleDateString()} + + ) : ( + + )} + + {isWorkspaceAdmin ? ( + + ) : null} + + + )) + )} +
+
+ + )} +
+ ); +} + +function LoadingSkeleton() { + return ( +
+ !SQUAD_DEFAULT_HIDDEN_COLUMNS.includes(key), + true, + )} + > + + + + + + + + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + + +
+ + +
+
+ + + + + + + + + +
+ ))} +
+
+ ); +}