feat(core): team surfaces data layer — scope plan, my-teams order, routes

Implements the team scope query plan (mirrors the project shape;
team_id is a real server filter) so team issue surfaces work end to
end. myTeamListOptions derives the sidebar's joined-teams view from the
shared list cache; useUpdateTeamMembership (fractional reorder) and
useReplaceTeamMembers (wholesale member set) drive the drag sort and
the member config panel. creationDefaultTeamId encodes the personal
default chain (my first team → workspace default → any). Adds
/team/:key/{issues,projects,autopilots,settings} paths, the /issue/:id
identifier-first detail path, the persisted sidebar collapse store, and
the create-team modal registration; quick-create drops its lastTeamId
memory in favor of the predictable order-first default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-07-03 17:22:14 +08:00
parent 208f253dc5
commit 725f815765
16 changed files with 268 additions and 24 deletions

View File

@@ -74,6 +74,8 @@ import type {
SendChatMessageResponse,
CancelTaskResponse,
Team,
TeamMembership,
ListTeamMembersResponse,
CreateTeamRequest,
UpdateTeamRequest,
ListTeamsResponse,
@@ -187,7 +189,10 @@ import {
EMPTY_LIST_AUTOPILOTS_RESPONSE,
ListIssuesResponseSchema,
ListTeamsResponseSchema,
ListTeamMembersResponseSchema,
EMPTY_LIST_TEAM_MEMBERS_RESPONSE,
TeamSchema,
TeamMembershipSchema,
ListWebhookDeliveriesResponseSchema,
RuntimeHourlyActivityListSchema,
RuntimeUsageByAgentListSchema,
@@ -1603,6 +1608,31 @@ export class ApiClient {
return parseOrWarn(raw, TeamSchema, { endpoint: "PATCH /api/teams/:id" });
}
async updateTeamMembership(id: string, data: { sort_order: number }): Promise<TeamMembership> {
const raw = await this.fetch<unknown>(`/api/teams/${id}/membership`, {
method: "PATCH",
body: JSON.stringify(data),
});
return parseOrWarn(raw, TeamMembershipSchema, { endpoint: "PATCH /api/teams/:id/membership" });
}
async replaceTeamMembers(id: string, memberIds: string[]): Promise<ListTeamMembersResponse> {
const raw = await this.fetch<unknown>(`/api/teams/${id}/members`, {
method: "PUT",
body: JSON.stringify({ member_ids: memberIds }),
});
return parseWithFallback(raw, ListTeamMembersResponseSchema, EMPTY_LIST_TEAM_MEMBERS_RESPONSE, {
endpoint: "PUT /api/teams/:id/members",
});
}
async listTeamMembers(id: string): Promise<ListTeamMembersResponse> {
const raw = await this.fetch<unknown>(`/api/teams/${id}/members`);
return parseWithFallback(raw, ListTeamMembersResponseSchema, EMPTY_LIST_TEAM_MEMBERS_RESPONSE, {
endpoint: "GET /api/teams/:id/members",
});
}
async archiveTeam(id: string): Promise<Team> {
const raw = await this.fetch<unknown>(`/api/teams/${id}`, { method: "DELETE" });
return parseOrWarn(raw, TeamSchema, { endpoint: "DELETE /api/teams/:id" });

View File

@@ -18,6 +18,7 @@ import type {
InboxWorkspaceUnread,
ListIssuesResponse,
ListTeamsResponse,
ListTeamMembersResponse,
ListWebhookDeliveriesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
@@ -324,8 +325,35 @@ export const TeamSchema = z.object({
created_by: z.string().nullable().default(null),
created_at: z.string().default(""),
updated_at: z.string().default(""),
is_member: z.boolean().default(false),
sort_order: z.number().default(0),
}).loose();
// PATCH /api/teams/{id}/membership — the caller's own sort position.
export const TeamMembershipSchema = z.object({
team_id: z.string().default(""),
sort_order: z.number().default(0),
}).loose();
export const TeamMemberSchema = z.object({
user_id: z.string(),
name: z.string().default(""),
email: z.string().default(""),
avatar_url: z.string().nullable().default(null),
role: z.string().default("member"),
created_at: z.string().default(""),
}).loose();
export const ListTeamMembersResponseSchema = z.object({
members: z.array(TeamMemberSchema).default([]),
total: z.number().default(0),
});
export const EMPTY_LIST_TEAM_MEMBERS_RESPONSE: ListTeamMembersResponse = {
members: [],
total: 0,
};
export const ListTeamsResponseSchema = z.object({
teams: z.array(TeamSchema).default([]),
total: z.number().default(0),
@@ -344,6 +372,8 @@ export const EMPTY_TEAM: Team = {
created_by: null,
created_at: "",
updated_at: "",
is_member: false,
sort_order: 0,
};
export const EMPTY_LIST_TEAMS_RESPONSE: ListTeamsResponse = {

View File

@@ -27,8 +27,6 @@ interface QuickCreateState {
setLastActor: (type: QuickCreateActorType | null, id: string | null) => void;
lastProjectId: string | null;
setLastProjectId: (id: string | null) => void;
lastTeamId: string | null;
setLastTeamId: (id: string | null) => void;
prompt: string;
setPrompt: (prompt: string) => void;
clearPrompt: () => void;
@@ -44,8 +42,6 @@ export const useQuickCreateStore = create<QuickCreateState>()(
setLastActor: (type, id) => set({ lastActorType: type, lastActorId: id }),
lastProjectId: null,
setLastProjectId: (id) => set({ lastProjectId: id }),
lastTeamId: null,
setLastTeamId: (id) => set({ lastTeamId: id }),
prompt: "",
setPrompt: (prompt) => set({ prompt }),
clearPrompt: () => set({ prompt: "" }),

View File

@@ -3,11 +3,7 @@ import type {
AssigneeGroupedIssuesFilter,
MyIssuesFilter,
} from "../queries";
import {
issueScopeKey,
UnsupportedIssueScopeError,
type IssueScope,
} from "./scope";
import { issueScopeKey, type IssueScope } from "./scope";
export type IssueSurfaceQueryPlan =
| {
@@ -163,7 +159,22 @@ export function buildIssueSurfaceQueryPlan(
: {},
};
}
case "team":
throw new UnsupportedIssueScopeError(scope, "issue surface query plan");
case "team": {
// Mirrors the project plan: team_id is a real server filter on both
// the list and grouped endpoints, so the team surface gets its own
// cache entry, and issues created from it default into the team.
const queryFilter = { team_id: scope.teamId };
return {
kind: "scoped",
scopeKey,
queryScope: scopeKey,
queryFilter,
groupedScopeFilter: queryFilter,
loadMoreScope: scopeKey,
loadMoreFilter: queryFilter,
userId: undefined,
createDefaults: { team_id: scope.teamId },
};
}
}
}

View File

@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
import {
UnsupportedIssueScopeError,
issueScopeKey,
} from "./scope";
import { issueScopeKey } from "./scope";
import { buildIssueSurfaceQueryPlan } from "./query-plan";
describe("issue surface scope", () => {
@@ -145,10 +142,16 @@ describe("issue surface scope", () => {
});
});
it("throws for team until the issue API has a team filter", () => {
const scope = { type: "team" as const, teamId: "t1" };
expect(() => buildIssueSurfaceQueryPlan(scope)).toThrow(
UnsupportedIssueScopeError,
);
it("builds the team query plan mirroring the project shape", () => {
expect(
buildIssueSurfaceQueryPlan({ type: "team", teamId: "t1" }),
).toMatchObject({
kind: "scoped",
scopeKey: "team:t1",
queryScope: "team:t1",
queryFilter: { team_id: "t1" },
groupedScopeFilter: { team_id: "t1" },
createDefaults: { team_id: "t1" },
});
});
});

View File

@@ -0,0 +1,32 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { defaultStorage } from "../platform/storage";
// Sidebar group collapse state, persisted per workspace (same
// workspace-aware storage the quick-create prefs use). Keys are group ids:
// "pinned", "workspace", "more", "teams", and `team:{teamId}` for each team
// group. Absent key = expanded — groups default open so a fresh workspace
// shows its whole structure.
interface SidebarState {
collapsed: Record<string, boolean>;
setGroupCollapsed: (key: string, collapsed: boolean) => void;
}
export const useSidebarStore = create<SidebarState>()(
persist(
(set) => ({
collapsed: {},
setGroupCollapsed: (key, collapsed) =>
set((s) => ({ collapsed: { ...s.collapsed, [key]: collapsed } })),
}),
{
name: "multica_sidebar",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useSidebarStore.persist.rehydrate());

View File

@@ -8,6 +8,7 @@ type ModalType =
| "quick-create-issue"
| "create-project"
| "create-squad"
| "create-team"
| "feedback"
| "issue-set-parent"
| "issue-add-child"

View File

@@ -73,6 +73,8 @@
"./teams": "./teams/index.ts",
"./teams/queries": "./teams/queries.ts",
"./teams/mutations": "./teams/mutations.ts",
"./teams/default-team": "./teams/default-team.ts",
"./layout/sidebar-store": "./layout/sidebar-store.ts",
"./labels": "./labels/index.ts",
"./labels/queries": "./labels/queries.ts",
"./labels/mutations": "./labels/mutations.ts",

View File

@@ -20,8 +20,19 @@ function workspaceScoped(slug: string) {
root: () => `${ws}/issues`,
usage: () => `${ws}/usage`,
issues: () => `${ws}/issues`,
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
// Issue detail is identifier-first (Linear-style /issue/NAI-3): the team
// rides in the identifier, never in a nested path segment, so moving an
// issue between teams can't orphan the URL (old identifiers keep
// resolving via the server-side alias). The same route accepts a UUID —
// internal navigation passes ids, shared links pass identifiers.
issueDetail: (idOrIdentifier: string) => `${ws}/issue/${encode(idOrIdentifier)}`,
teams: () => `${ws}/teams`,
// Team-scoped surfaces, addressed by team key (readable, stable: keys
// freeze once a team has issues) — /team/ENG/issues, Linear-style.
teamIssues: (key: string) => `${ws}/team/${encode(key)}/issues`,
teamProjects: (key: string) => `${ws}/team/${encode(key)}/projects`,
teamAutopilots: (key: string) => `${ws}/team/${encode(key)}/autopilots`,
teamSettings: (key: string) => `${ws}/team/${encode(key)}/settings`,
projects: () => `${ws}/projects`,
projectDetail: (id: string) => `${ws}/projects/${encode(id)}`,
autopilots: () => `${ws}/autopilots`,

View File

@@ -90,6 +90,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"settings",
"workspaces",
"teams",
"team",
// API / integration prefixes
// `api` above already covers `/api/*`; these guard against future top-level

View File

@@ -0,0 +1,15 @@
import type { Team } from "../types";
/**
* The team a new issue defaults into when no other context (route, parent,
* single-team project) applies: the first team in the user's personal order.
* Falls back to the workspace default team for users whose membership rows
* predate the membership rollout, then to any active team.
*/
export function creationDefaultTeamId(teams: Team[]): string | undefined {
const active = teams.filter((team) => !team.archived_at);
const mine = active
.filter((team) => team.is_member)
.sort((a, b) => a.sort_order - b.sort_order);
return (mine[0] ?? active.find((team) => team.is_default) ?? active[0])?.id;
}

View File

@@ -1,2 +1,15 @@
export { teamKeys, teamListOptions, activeTeamListOptions } from "./queries";
export { useCreateTeam, useUpdateTeam, useArchiveTeam } from "./mutations";
export {
teamKeys,
teamListOptions,
activeTeamListOptions,
myTeamListOptions,
teamMembersOptions,
} from "./queries";
export {
useCreateTeam,
useUpdateTeam,
useArchiveTeam,
useUpdateTeamMembership,
useReplaceTeamMembers,
} from "./mutations";
export { creationDefaultTeamId } from "./default-team";

View File

@@ -54,6 +54,53 @@ export function useUpdateTeam() {
});
}
export function useUpdateTeamMembership() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ id, sort_order }: { id: string; sort_order: number }) =>
api.updateTeamMembership(id, { sort_order }),
// Optimistic: sidebar ordering must not snap back while the PATCH is in
// flight. Fractional sort keys mean only the dragged team's row changes.
onMutate: async ({ id, sort_order }) => {
await qc.cancelQueries({ queryKey: teamKeys.list(wsId) });
const prevList = qc.getQueryData<ListTeamsResponse>(teamKeys.list(wsId));
qc.setQueryData<ListTeamsResponse>(teamKeys.list(wsId), (old) =>
old
? {
...old,
teams: old.teams.map((t) => (t.id === id ? { ...t, sort_order } : t)),
}
: old,
);
return { prevList };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(teamKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: teamKeys.all(wsId) });
},
});
}
export function useReplaceTeamMembers() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ id, member_ids }: { id: string; member_ids: string[] }) =>
api.replaceTeamMembers(id, member_ids),
// No optimistic patch: the config panel closes on save, and the
// caller's own is_member may flip (they can add/remove themselves), so
// one settled invalidation of the list + members caches is the simplest
// correct reconcile.
onSettled: (_data, _err, { id }) => {
qc.invalidateQueries({ queryKey: teamKeys.all(wsId) });
qc.invalidateQueries({ queryKey: teamKeys.members(wsId, id) });
},
});
}
export function useArchiveTeam() {
const qc = useQueryClient();
const wsId = useWorkspaceId();

View File

@@ -4,6 +4,8 @@ import { api } from "../api";
export const teamKeys = {
all: (wsId: string) => ["teams", wsId] as const,
list: (wsId: string) => [...teamKeys.all(wsId), "list"] as const,
members: (wsId: string, teamId: string) =>
[...teamKeys.all(wsId), "members", teamId] as const,
};
export function teamListOptions(wsId: string) {
@@ -24,3 +26,25 @@ export function activeTeamListOptions(wsId: string) {
select: (data) => data.teams.filter((team) => !team.archived_at),
});
}
export function teamMembersOptions(wsId: string, teamId: string) {
return queryOptions({
queryKey: teamKeys.members(wsId, teamId),
queryFn: () => api.listTeamMembers(teamId),
select: (data) => data.members,
});
}
export function myTeamListOptions(wsId: string) {
// The sidebar's Teams section: only teams the user joined, in their
// personal order. Same cache entry as teamListOptions (per-observer
// select), so reorder patches on the base key reflect here instantly.
return queryOptions({
queryKey: teamKeys.list(wsId),
queryFn: () => api.listTeams(),
select: (data) =>
data.teams
.filter((team) => team.is_member && !team.archived_at)
.sort((a, b) => a.sort_order - b.sort_order),
});
}

View File

@@ -61,7 +61,7 @@ export type {
} from "./agent";
export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { Team, CreateTeamRequest, UpdateTeamRequest, ListTeamsResponse } from "./team";
export type { Team, CreateTeamRequest, UpdateTeamRequest, ListTeamsResponse, TeamMembership, TeamMember, ListTeamMembersResponse } from "./team";
export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread } from "./inbox";
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
export type { Comment, CommentType, CommentAuthorType, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, Reaction } from "./comment";

View File

@@ -11,6 +11,10 @@ export interface Team {
created_by: string | null;
created_at: string;
updated_at: string;
/** Requesting user's membership view — the sidebar shows only joined
* teams, ordered by sort_order (per-user fractional position). */
is_member: boolean;
sort_order: number;
}
export interface CreateTeamRequest {
@@ -18,6 +22,8 @@ export interface CreateTeamRequest {
key: string;
description?: string;
icon?: string | null;
/** Workspace members invited alongside the creator (who joins as lead). */
member_ids?: string[];
}
export interface UpdateTeamRequest {
@@ -31,3 +37,25 @@ export interface ListTeamsResponse {
teams: Team[];
total: number;
}
/** Caller's own membership row, as returned by PATCH /api/teams/{id}/membership. */
export interface TeamMembership {
team_id: string;
sort_order: number;
}
/** A team member with user display data (GET /api/teams/{id}/members). */
export interface TeamMember {
user_id: string;
name: string;
email: string;
avatar_url: string | null;
/** "lead" | "member" — informational in v1, no privileges attached. */
role: string;
created_at: string;
}
export interface ListTeamMembersResponse {
members: TeamMember[];
total: number;
}