diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index a0a034919..db1b012a7 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -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 { + const raw = await this.fetch(`/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 { + const raw = await this.fetch(`/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 { + const raw = await this.fetch(`/api/teams/${id}/members`); + return parseWithFallback(raw, ListTeamMembersResponseSchema, EMPTY_LIST_TEAM_MEMBERS_RESPONSE, { + endpoint: "GET /api/teams/:id/members", + }); + } + async archiveTeam(id: string): Promise { const raw = await this.fetch(`/api/teams/${id}`, { method: "DELETE" }); return parseOrWarn(raw, TeamSchema, { endpoint: "DELETE /api/teams/:id" }); diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 77168887d..bf02589b4 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -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 = { diff --git a/packages/core/issues/stores/quick-create-store.ts b/packages/core/issues/stores/quick-create-store.ts index 11724f866..93dae2922 100644 --- a/packages/core/issues/stores/quick-create-store.ts +++ b/packages/core/issues/stores/quick-create-store.ts @@ -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()( 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: "" }), diff --git a/packages/core/issues/surface/query-plan.ts b/packages/core/issues/surface/query-plan.ts index 5ea411b41..7f46d8516 100644 --- a/packages/core/issues/surface/query-plan.ts +++ b/packages/core/issues/surface/query-plan.ts @@ -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 }, + }; + } } } diff --git a/packages/core/issues/surface/scope.test.ts b/packages/core/issues/surface/scope.test.ts index 7b08477be..b0ad70745 100644 --- a/packages/core/issues/surface/scope.test.ts +++ b/packages/core/issues/surface/scope.test.ts @@ -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" }, + }); }); }); diff --git a/packages/core/layout/sidebar-store.ts b/packages/core/layout/sidebar-store.ts new file mode 100644 index 000000000..f2dac91ee --- /dev/null +++ b/packages/core/layout/sidebar-store.ts @@ -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; + setGroupCollapsed: (key: string, collapsed: boolean) => void; +} + +export const useSidebarStore = create()( + persist( + (set) => ({ + collapsed: {}, + setGroupCollapsed: (key, collapsed) => + set((s) => ({ collapsed: { ...s.collapsed, [key]: collapsed } })), + }), + { + name: "multica_sidebar", + storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)), + }, + ), +); + +registerForWorkspaceRehydration(() => useSidebarStore.persist.rehydrate()); diff --git a/packages/core/modals/store.ts b/packages/core/modals/store.ts index cb4aae4be..c02d23be3 100644 --- a/packages/core/modals/store.ts +++ b/packages/core/modals/store.ts @@ -8,6 +8,7 @@ type ModalType = | "quick-create-issue" | "create-project" | "create-squad" + | "create-team" | "feedback" | "issue-set-parent" | "issue-add-child" diff --git a/packages/core/package.json b/packages/core/package.json index 76b456d33..17ade6ada 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/paths/paths.ts b/packages/core/paths/paths.ts index cd0b3d5d8..df9ace968 100644 --- a/packages/core/paths/paths.ts +++ b/packages/core/paths/paths.ts @@ -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`, diff --git a/packages/core/paths/reserved-slugs.ts b/packages/core/paths/reserved-slugs.ts index efafda502..73e762a8f 100644 --- a/packages/core/paths/reserved-slugs.ts +++ b/packages/core/paths/reserved-slugs.ts @@ -90,6 +90,7 @@ export const RESERVED_SLUGS: ReadonlySet = new Set([ "settings", "workspaces", "teams", + "team", // API / integration prefixes // `api` above already covers `/api/*`; these guard against future top-level diff --git a/packages/core/teams/default-team.ts b/packages/core/teams/default-team.ts new file mode 100644 index 000000000..a77665dfe --- /dev/null +++ b/packages/core/teams/default-team.ts @@ -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; +} diff --git a/packages/core/teams/index.ts b/packages/core/teams/index.ts index cd38dbd29..8c78026d5 100644 --- a/packages/core/teams/index.ts +++ b/packages/core/teams/index.ts @@ -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"; diff --git a/packages/core/teams/mutations.ts b/packages/core/teams/mutations.ts index 379880dee..e9373bf75 100644 --- a/packages/core/teams/mutations.ts +++ b/packages/core/teams/mutations.ts @@ -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(teamKeys.list(wsId)); + qc.setQueryData(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(); diff --git a/packages/core/teams/queries.ts b/packages/core/teams/queries.ts index f66dc5b12..41f94cd2a 100644 --- a/packages/core/teams/queries.ts +++ b/packages/core/teams/queries.ts @@ -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), + }); +} diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index f263aa89b..335c63192 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -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"; diff --git a/packages/core/types/team.ts b/packages/core/types/team.ts index 156e53344..486f35fab 100644 --- a/packages/core/types/team.ts +++ b/packages/core/types/team.ts @@ -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; +}