feat(squads): rebuild list on shared ListGrid (identity rows, minimal)

The last list joins the family. Squads are the fewest entity (1-5 rows),
so this is the agents identity-row shell on the runtime-list minimal
skeleton: ListGrid subgrid + var tracks + two-zone responsiveness +
single scroll container, but NO virtualization, checkbox, or batch.

- Identity two-line rows (squad avatar + name + description, 64px) like
  agents; columns: name / leader / members (polymorphic ActorAvatar
  stack from member_preview), creator + created opt-in hidden
- Scope Mine/All (creator-based, issues-header styling, <md dropdown);
  no archived scope (list API hard-filters archived + no restore
  endpoint), no search (scope-bearing), no filters (set too small)
- Sort name (default) / members / created
- Row kebab = Archive (= the delete endpoint, which archives + transfers
  issues/autopilots to the leader); workspace owner/admin only, so the
  kebab track collapses for non-admins. Reuses the existing
  archive_dialog copy. No batch.
- View store extended (scope + sort + columns); zero API change — pure
  frontend (member_preview/count already in the list payload)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-06-15 09:52:58 +08:00
parent ca2b7a58c0
commit 1eb13835b4
8 changed files with 1002 additions and 212 deletions

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff