diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 428fe0e4a..1cd6e2971 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -131,6 +131,8 @@ import { EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE, EMPTY_GROUPED_ISSUES_RESPONSE, EMPTY_LIST_ISSUES_RESPONSE, + EMPTY_SQUAD, + EMPTY_SQUAD_LIST, EMPTY_SQUAD_MEMBER_STATUS_LIST, EMPTY_TIMELINE_ENTRIES, EMPTY_USER, @@ -143,6 +145,8 @@ import { RuntimeUsageByAgentListSchema, RuntimeUsageByHourListSchema, RuntimeUsageListSchema, + SquadSchema, + SquadListSchema, SquadMemberStatusListResponseSchema, SubscribersListSchema, TimelineEntriesSchema, @@ -1574,19 +1578,31 @@ export class ApiClient { // Squads async listSquads(): Promise { - return this.fetch(`/api/squads`); + const raw = await this.fetch(`/api/squads`); + return parseWithFallback(raw, SquadListSchema, EMPTY_SQUAD_LIST, { + endpoint: "GET /api/squads", + }) as Squad[]; } async getSquad(id: string): Promise { - return this.fetch(`/api/squads/${id}`); + const raw = await this.fetch(`/api/squads/${id}`); + return parseWithFallback(raw, SquadSchema, EMPTY_SQUAD, { + endpoint: "GET /api/squads/:id", + }) as Squad; } async createSquad(data: { name: string; description?: string; leader_id: string; avatar_url?: string }): Promise { - return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) }); + const raw = await this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) }); + return parseWithFallback(raw, SquadSchema, EMPTY_SQUAD, { + endpoint: "POST /api/squads", + }) as Squad; } async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise { - return this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) }); + const raw = await this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) }); + return parseWithFallback(raw, SquadSchema, EMPTY_SQUAD, { + endpoint: "PUT /api/squads/:id", + }) as Squad; } async deleteSquad(id: string): Promise { diff --git a/packages/core/api/schemas.test.ts b/packages/core/api/schemas.test.ts index 5837796c1..f54420284 100644 --- a/packages/core/api/schemas.test.ts +++ b/packages/core/api/schemas.test.ts @@ -10,6 +10,8 @@ import { RuntimeUsageByAgentListSchema, RuntimeUsageByHourListSchema, RuntimeUsageListSchema, + SquadListSchema, + SquadSchema, UserSchema, } from "./schemas"; import { parseWithFallback } from "./schema"; @@ -164,6 +166,51 @@ describe("UserSchema timezone drift", () => { }); }); +describe("SquadListSchema member preview drift", () => { + const baseSquad = { + id: "squad-1", + workspace_id: "ws-1", + name: "Frontend Squad", + description: "", + instructions: "", + avatar_url: null, + leader_id: "agent-1", + creator_id: "user-1", + created_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + archived_at: null, + archived_by: null, + }; + + it("defaults preview fields when an older backend omits them", () => { + const parsed = SquadListSchema.parse([baseSquad]); + expect(parsed[0]?.member_count).toBe(0); + expect(parsed[0]?.member_preview).toEqual([]); + }); + + it("defaults preview fields on a single squad response", () => { + const parsed = SquadSchema.parse(baseSquad); + expect(parsed.member_count).toBe(0); + expect(parsed.member_preview).toEqual([]); + }); + + it("preserves lightweight member preview rows", () => { + const parsed = SquadListSchema.parse([ + { + ...baseSquad, + member_count: 2, + member_preview: [ + { member_type: "agent", member_id: "agent-1", role: "leader" }, + { member_type: "member", member_id: "user-2", role: "member" }, + ], + }, + ]); + expect(parsed[0]?.member_count).toBe(2); + expect(parsed[0]?.member_preview).toHaveLength(2); + expect(parsed[0]?.member_preview?.[0]?.role).toBe("leader"); + }); +}); + // The workspace dashboard and runtime-detail pages were re-pointed at the // unified `task_usage_hourly` rollup. Every numeric field drives chart / // KPI math, and string keys (date / agent_id / model) bucket the series. diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 1222dc933..9b2ba0548 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -8,6 +8,7 @@ import type { GroupedIssuesResponse, ListIssuesResponse, ListWebhookDeliveriesResponse, + Squad, TimelineEntry, User, WebhookDelivery, @@ -432,6 +433,51 @@ export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateR reused_skill_ids: [], }; +// Squad list responses carry lightweight membership previews used by hover +// cards. The preview fields are additive API fields, so older backends default +// cleanly to no preview instead of breaking newer frontends. +const SquadMemberPreviewSchema = z.object({ + member_type: z.string(), + member_id: z.string(), + role: z.string().default(""), +}).loose(); + +export const SquadSchema = z.object({ + id: z.string(), + workspace_id: z.string(), + name: z.string(), + description: z.string().default(""), + instructions: z.string().default(""), + avatar_url: z.string().nullable().optional().transform((v) => v ?? null), + leader_id: z.string(), + creator_id: z.string(), + created_at: z.string(), + updated_at: z.string(), + archived_at: z.string().nullable().optional().transform((v) => v ?? null), + archived_by: z.string().nullable().optional().transform((v) => v ?? null), + member_count: z.number().default(0), + member_preview: z.array(SquadMemberPreviewSchema).default([]), +}).loose(); + +export const SquadListSchema = z.array(SquadSchema); +export const EMPTY_SQUAD_LIST: Squad[] = []; +export const EMPTY_SQUAD: Squad = { + id: "", + workspace_id: "", + name: "", + description: "", + instructions: "", + avatar_url: null, + leader_id: "", + creator_id: "", + created_at: "", + updated_at: "", + archived_at: null, + archived_by: null, + member_count: 0, + member_preview: [], +}; + // Squad member status — backs the Squad detail page's Members tab. status // is `string | null` (not the narrow `SquadMemberStatusValue` union) so a // new server-side status doesn't fail the parse; the UI defaults to a diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 91a5165b0..309bd4ede 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -165,6 +165,20 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void { qc.invalidateQueries({ queryKey: workspaceKeys.list() }); } +function invalidateSquadMemberStatusQueries(qc: QueryClient, wsId: string): void { + qc.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + key[0] === "workspaces" && + key[1] === wsId && + key[2] === "squads" && + key[4] === "members-status" + ); + }, + }); +} + export interface RealtimeSyncStores { authStore: UseBoundStore>; } @@ -215,10 +229,10 @@ export function useRealtimeSync( if (wsId) { qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }); // Squad members status is derived per agent, so any agent - // change (status flip, archive, runtime swap) needs to refresh - // the per-squad members-status cache. Prefix-matches both the - // squad list and every squadMemberStatus query. - qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) }); + // change (status flip, archive, runtime swap) needs to refresh the + // per-squad members-status cache without refetching the static squad + // list summary. + invalidateSquadMemberStatusQueries(qc, wsId); } }, member: () => { @@ -271,9 +285,8 @@ export function useRealtimeSync( qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) }); // Runtime online/offline transitions move the derived status // for every agent that hosts on this runtime, which shifts the - // working/idle/offline pill on the squad page. Same prefix - // invalidation pattern as the agent handler above. - qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) }); + // working/idle/offline pill on the squad page. + invalidateSquadMemberStatusQueries(qc, wsId); } }, autopilot: () => { @@ -321,9 +334,8 @@ export function useRealtimeSync( // event shifts the aggregated usage numbers. qc.invalidateQueries({ queryKey: ["issues", "usage"] }); // Squad members-status reads the same task lifecycle to flip - // working ↔ idle for each agent member. Prefix-matches every - // mounted squad-page's members-status query in O(1). - qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) }); + // working ↔ idle for each agent member. + invalidateSquadMemberStatusQueries(qc, wsId); }, }; diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index 2612ffefb..53d7eb9d2 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -114,6 +114,7 @@ export type { Squad, SquadMember, SquadMemberType, + SquadMemberPreview, SquadActivityLog, SquadActivityOutcome, CreateSquadRequest, diff --git a/packages/core/types/squad.ts b/packages/core/types/squad.ts index 55103da0c..73f80b582 100644 --- a/packages/core/types/squad.ts +++ b/packages/core/types/squad.ts @@ -2,6 +2,12 @@ export type SquadMemberType = "agent" | "member"; export type SquadActivityOutcome = "action" | "no_action" | "failed"; +export interface SquadMemberPreview { + member_type: SquadMemberType; + member_id: string; + role: string; +} + export interface Squad { id: string; workspace_id: string; @@ -15,6 +21,8 @@ export interface Squad { updated_at: string; archived_at: string | null; archived_by: string | null; + member_count?: number; + member_preview?: SquadMemberPreview[]; } export interface SquadMember { diff --git a/packages/views/issues/components/board-card.tsx b/packages/views/issues/components/board-card.tsx index e1b976285..4282adb52 100644 --- a/packages/views/issues/components/board-card.tsx +++ b/packages/views/issues/components/board-card.tsx @@ -105,13 +105,13 @@ export const BoardCardContent = memo(function BoardCardContent({ const showChildProgress = storeProperties.childProgress && childProgress; const showLabels = storeProperties.labels && labels.length > 0; - // When assignee is the only meta-row property, expand it with name on the left - // and a relative "updated" hint on the right — fills the otherwise empty row. - const metaIsAssigneeOnly = - !!showAssignee && !showStartDate && !showDueDate && !showChildProgress; + // Dates need the horizontal space; compact child progress can share the row + // with assignee identity. + const showAssigneeName = !!showAssignee && !showStartDate && !showDueDate; + const showUpdatedHint = showAssigneeName && !showChildProgress; const { getActorName } = useActorName(); const assigneeName = - metaIsAssigneeOnly && issue.assignee_type && issue.assignee_id + showAssigneeName && issue.assignee_type && issue.assignee_id ? getActorName(issue.assignee_type, issue.assignee_id) : null; @@ -140,23 +140,23 @@ export const BoardCardContent = memo(function BoardCardContent({ ) ) : null; - // When showing only an avatar (no name), keep natural width; when showing - // avatar + name, allow the container to shrink (min-w-0) and cap with a - // sensible max so a 30-char agent name can't push the rest of the row off. + // The parent row gives this container the leftover space; min-w-0 and + // max-w-full make the nested picker trigger respect that limit. const assigneeContainerClass = assigneeName - ? "inline-flex items-center gap-1.5 min-w-0 max-w-[160px]" + ? "flex min-w-0 max-w-full items-center" : "inline-flex items-center"; const assigneeInner = showAssignee ? ( - + {assigneeName && ( - {assigneeName} + {assigneeName} )} ) : null; @@ -177,6 +177,7 @@ export const BoardCardContent = memo(function BoardCardContent({ ) : null; const showMetaRow = showAssignee || showStartDate || showDueDate || showChildProgress; + const showRightMeta = !!showStartDate || !!showDueDate || !!showChildProgress || showUpdatedHint; return (
@@ -221,74 +222,82 @@ export const BoardCardContent = memo(function BoardCardContent({ {/* Meta row: assignee (left), start date, due date, child progress (right) */} {showMetaRow && ( -
- {assigneeNode} - {showStartDate && ( - editable ? ( - - - - {formatDate(issue.start_date!)} - - } - /> - - ) : ( - - - {formatDate(issue.start_date!)} - - ) - )} - {showDueDate && ( - editable ? ( - - - - {formatDate(issue.due_date!)} - - } - /> - - ) : ( - - - {formatDate(issue.due_date!)} - - ) - )} - {showChildProgress && ( -
- - - {childProgress!.done}/{childProgress!.total} - +
+ {showAssignee && ( +
+ {assigneeNode}
)} - {metaIsAssigneeOnly && ( - - {t(($) => $.card.updated_ago, { time: timeAgo(issue.updated_at) })} - + {showRightMeta && ( +
+ {showStartDate && ( + editable ? ( + + + + {formatDate(issue.start_date!)} + + } + /> + + ) : ( + + + {formatDate(issue.start_date!)} + + ) + )} + {showDueDate && ( + editable ? ( + + + + {formatDate(issue.due_date!)} + + } + /> + + ) : ( + + + {formatDate(issue.due_date!)} + + ) + )} + {showChildProgress && ( +
+ + + {childProgress!.done}/{childProgress!.total} + +
+ )} + {showUpdatedHint && ( + + {t(($) => $.card.updated_ago, { time: timeAgo(issue.updated_at) })} + + )} +
)}
)} diff --git a/packages/views/squads/components/squad-profile-card.tsx b/packages/views/squads/components/squad-profile-card.tsx index 04fc74ba8..d6818ae81 100644 --- a/packages/views/squads/components/squad-profile-card.tsx +++ b/packages/views/squads/components/squad-profile-card.tsx @@ -1,11 +1,10 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import type { SquadMemberStatus } from "@multica/core/types"; +import type { SquadMemberPreview } from "@multica/core/types"; import { useWorkspaceId } from "@multica/core/hooks"; import { squadListOptions, - squadMemberStatusOptions, agentListOptions, memberListOptions, } from "@multica/core/workspace/queries"; @@ -20,13 +19,6 @@ interface SquadProfileCardProps { squadId: string; } -const STATUS_DOT_CLASS: Record = { - working: "bg-success", - idle: "bg-muted-foreground/40", - offline: "bg-muted-foreground/40", - unstable: "bg-warning", -}; - export function SquadProfileCard({ squadId }: SquadProfileCardProps) { const { t } = useT("squads"); const wsId = useWorkspaceId(); @@ -36,9 +28,6 @@ export function SquadProfileCard({ squadId }: SquadProfileCardProps) { ); const { data: agents = [] } = useQuery(agentListOptions(wsId)); const { data: wsMembers = [] } = useQuery(memberListOptions(wsId)); - const { data: memberStatusResp } = useQuery( - squadMemberStatusOptions(wsId, squadId), - ); const squad = squads.find((s) => s.id === squadId); @@ -70,14 +59,8 @@ export function SquadProfileCard({ squadId }: SquadProfileCardProps) { .toUpperCase() .slice(0, 2); - const memberStatuses = memberStatusResp?.members ?? []; - const leaderFirst = [...memberStatuses].sort((a, b) => { - const aLeader = a.member_type === "agent" && a.member_id === squad.leader_id; - const bLeader = b.member_type === "agent" && b.member_id === squad.leader_id; - if (aLeader && !bLeader) return -1; - if (!aLeader && bLeader) return 1; - return 0; - }); + const memberPreview = squad.member_preview ?? []; + const memberCount = squad.member_count ?? memberPreview.length; return (
@@ -116,9 +99,10 @@ export function SquadProfileCard({ squadId }: SquadProfileCardProps) {

)} - {leaderFirst.length > 0 && ( + {memberCount > 0 && ( {t(($) => $.profile_card.members_section)} - · {members.length} + · {memberCount} -
+
{visible.map((m) => { const isLeader = m.member_type === "agent" && m.member_id === leaderId; @@ -160,20 +146,6 @@ function MembersList({ m.member_id.slice(0, 8) : wsMembers.find((u) => u.user_id === m.member_id)?.name ?? m.member_id.slice(0, 8); - const statusDotClass = - m.status && m.status in STATUS_DOT_CLASS - ? STATUS_DOT_CLASS[m.status] - : null; - const statusLabel = - m.status === "working" - ? t(($) => $.members_tab.status_working) - : m.status === "idle" - ? t(($) => $.members_tab.status_idle) - : m.status === "offline" - ? t(($) => $.members_tab.status_offline) - : m.status === "unstable" - ? t(($) => $.members_tab.status_unstable) - : null; const href = m.member_type === "agent" ? p.agentDetail(m.member_id) @@ -187,7 +159,7 @@ function MembersList({ - {name} + {name} {isLeader && ( - + {t(($) => $.members_tab.leader_chip)} )} - {m.member_type === "agent" && statusLabel && ( - - - {statusLabel} - - )} {m.member_type === "member" && memberRole && ( - + {memberRole} )} @@ -219,7 +183,7 @@ function MembersList({ ); })} {overflow > 0 && ( - + {t(($) => $.profile_card.more_members, { count: overflow })} )} diff --git a/server/internal/handler/squad.go b/server/internal/handler/squad.go index 902e0974d..0e580186c 100644 --- a/server/internal/handler/squad.go +++ b/server/internal/handler/squad.go @@ -19,18 +19,31 @@ import ( // ── Response types ────────────────────────────────────────────────────────── type SquadResponse struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - Description string `json:"description"` - Instructions string `json:"instructions"` - AvatarURL *string `json:"avatar_url"` - LeaderID string `json:"leader_id"` - CreatorID string `json:"creator_id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - ArchivedAt *string `json:"archived_at"` - ArchivedBy *string `json:"archived_by"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + Description string `json:"description"` + Instructions string `json:"instructions"` + AvatarURL *string `json:"avatar_url"` + LeaderID string `json:"leader_id"` + CreatorID string `json:"creator_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ArchivedAt *string `json:"archived_at"` + ArchivedBy *string `json:"archived_by"` + MemberCount int `json:"member_count"` + MemberPreview []SquadMemberPreviewResponse `json:"member_preview"` +} + +type SquadMemberPreviewResponse struct { + MemberType string `json:"member_type"` + MemberID string `json:"member_id"` + Role string `json:"role"` +} + +type squadMemberSummary struct { + count int + preview []SquadMemberPreviewResponse } type SquadMemberResponse struct { @@ -46,18 +59,19 @@ type SquadMemberResponse struct { func squadToResponse(s db.Squad) SquadResponse { return SquadResponse{ - ID: uuidToString(s.ID), - WorkspaceID: uuidToString(s.WorkspaceID), - Name: s.Name, - Description: s.Description, - Instructions: s.Instructions, - AvatarURL: textToPtr(s.AvatarUrl), - LeaderID: uuidToString(s.LeaderID), - CreatorID: uuidToString(s.CreatorID), - CreatedAt: timestampToString(s.CreatedAt), - UpdatedAt: timestampToString(s.UpdatedAt), - ArchivedAt: timestampToPtr(s.ArchivedAt), - ArchivedBy: uuidToPtr(s.ArchivedBy), + ID: uuidToString(s.ID), + WorkspaceID: uuidToString(s.WorkspaceID), + Name: s.Name, + Description: s.Description, + Instructions: s.Instructions, + AvatarURL: textToPtr(s.AvatarUrl), + LeaderID: uuidToString(s.LeaderID), + CreatorID: uuidToString(s.CreatorID), + CreatedAt: timestampToString(s.CreatedAt), + UpdatedAt: timestampToString(s.UpdatedAt), + ArchivedAt: timestampToPtr(s.ArchivedAt), + ArchivedBy: uuidToPtr(s.ArchivedBy), + MemberPreview: []SquadMemberPreviewResponse{}, } } @@ -72,6 +86,26 @@ func squadMemberToResponse(m db.SquadMember) SquadMemberResponse { } } +func addSquadMemberPreview(summary *squadMemberSummary, memberType string, memberID pgtype.UUID, role string) { + summary.count++ + if len(summary.preview) >= 3 { + return + } + summary.preview = append(summary.preview, SquadMemberPreviewResponse{ + MemberType: memberType, + MemberID: uuidToString(memberID), + Role: role, + }) +} + +func applySquadMemberSummary(resp *SquadResponse, summary *squadMemberSummary) { + if summary == nil { + return + } + resp.MemberCount = summary.count + resp.MemberPreview = summary.preview +} + // ── Helpers ───────────────────────────────────────────────────────────────── // loadSquadInWorkspace loads a squad scoped to the current workspace. @@ -97,6 +131,28 @@ func (h *Handler) loadSquadInWorkspace(w http.ResponseWriter, r *http.Request) ( return squad, workspaceID, true } +func (h *Handler) loadSquadMemberSummary(ctx context.Context, squadID pgtype.UUID) (*squadMemberSummary, error) { + rows, err := h.Queries.ListSquadMemberPreviewRowsBySquad(ctx, squadID) + if err != nil { + return nil, err + } + summary := &squadMemberSummary{} + for _, row := range rows { + addSquadMemberPreview(summary, row.MemberType, row.MemberID, row.Role) + } + return summary, nil +} + +func (h *Handler) squadToResponseWithPreview(ctx context.Context, squad db.Squad) (SquadResponse, error) { + resp := squadToResponse(squad) + summary, err := h.loadSquadMemberSummary(ctx, squad.ID) + if err != nil { + return resp, err + } + applySquadMemberSummary(&resp, summary) + return resp, nil +} + // ── Handlers ──────────────────────────────────────────────────────────────── func (h *Handler) ListSquads(w http.ResponseWriter, r *http.Request) { @@ -110,9 +166,27 @@ func (h *Handler) ListSquads(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to list squads") return } + + previewRows, err := h.Queries.ListSquadMemberPreviewRows(r.Context(), wsUUID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list squad member preview") + return + } + summaries := make(map[string]*squadMemberSummary, len(squads)) + for _, row := range previewRows { + squadID := uuidToString(row.SquadID) + summary := summaries[squadID] + if summary == nil { + summary = &squadMemberSummary{} + summaries[squadID] = summary + } + addSquadMemberPreview(summary, row.MemberType, row.MemberID, row.Role) + } + resp := make([]SquadResponse, len(squads)) for i, s := range squads { resp[i] = squadToResponse(s) + applySquadMemberSummary(&resp[i], summaries[uuidToString(s.ID)]) } writeJSON(w, http.StatusOK, resp) } @@ -188,7 +262,11 @@ func (h *Handler) CreateSquad(w http.ResponseWriter, r *http.Request) { Role: "leader", }) - resp := squadToResponse(squad) + resp, err := h.squadToResponseWithPreview(r.Context(), squad) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load squad member preview") + return + } h.publish(protocol.EventSquadCreated, workspaceID, "member", uuidToString(member.UserID), map[string]any{"squad": resp}) writeJSON(w, http.StatusCreated, resp) } @@ -198,7 +276,12 @@ func (h *Handler) GetSquad(w http.ResponseWriter, r *http.Request) { if !ok { return } - writeJSON(w, http.StatusOK, squadToResponse(squad)) + resp, err := h.squadToResponseWithPreview(r.Context(), squad) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load squad member preview") + return + } + writeJSON(w, http.StatusOK, resp) } func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) { @@ -271,7 +354,11 @@ func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) { return } - resp := squadToResponse(updated) + resp, err := h.squadToResponseWithPreview(r.Context(), updated) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load squad member preview") + return + } h.publish(protocol.EventSquadUpdated, workspaceID, "member", requestUserID(r), map[string]any{"squad": resp}) writeJSON(w, http.StatusOK, resp) } diff --git a/server/pkg/db/generated/squad.sql.go b/server/pkg/db/generated/squad.sql.go index 725faac36..27711b8bb 100644 --- a/server/pkg/db/generated/squad.sql.go +++ b/server/pkg/db/generated/squad.sql.go @@ -267,6 +267,102 @@ func (q *Queries) ListAllSquads(ctx context.Context, workspaceID pgtype.UUID) ([ return items, nil } +const listSquadMemberPreviewRows = `-- name: ListSquadMemberPreviewRows :many +SELECT + sm.squad_id, + sm.member_type, + sm.member_id, + sm.role +FROM squad_member sm +JOIN squad s ON s.id = sm.squad_id +WHERE s.workspace_id = $1 AND s.archived_at IS NULL +ORDER BY + sm.squad_id ASC, + (sm.member_type = 'agent' AND sm.member_id = s.leader_id) DESC, + sm.created_at ASC +` + +type ListSquadMemberPreviewRowsRow struct { + SquadID pgtype.UUID `json:"squad_id"` + MemberType string `json:"member_type"` + MemberID pgtype.UUID `json:"member_id"` + Role string `json:"role"` +} + +// Static squad membership summary for list/hover previews. This deliberately +// excludes derived runtime/task status; the squad detail members-status +// endpoint owns live state. +func (q *Queries) ListSquadMemberPreviewRows(ctx context.Context, workspaceID pgtype.UUID) ([]ListSquadMemberPreviewRowsRow, error) { + rows, err := q.db.Query(ctx, listSquadMemberPreviewRows, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListSquadMemberPreviewRowsRow{} + for rows.Next() { + var i ListSquadMemberPreviewRowsRow + if err := rows.Scan( + &i.SquadID, + &i.MemberType, + &i.MemberID, + &i.Role, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSquadMemberPreviewRowsBySquad = `-- name: ListSquadMemberPreviewRowsBySquad :many +SELECT + sm.squad_id, + sm.member_type, + sm.member_id, + sm.role +FROM squad_member sm +JOIN squad s ON s.id = sm.squad_id +WHERE sm.squad_id = $1 +ORDER BY + (sm.member_type = 'agent' AND sm.member_id = s.leader_id) DESC, + sm.created_at ASC +` + +type ListSquadMemberPreviewRowsBySquadRow struct { + SquadID pgtype.UUID `json:"squad_id"` + MemberType string `json:"member_type"` + MemberID pgtype.UUID `json:"member_id"` + Role string `json:"role"` +} + +func (q *Queries) ListSquadMemberPreviewRowsBySquad(ctx context.Context, squadID pgtype.UUID) ([]ListSquadMemberPreviewRowsBySquadRow, error) { + rows, err := q.db.Query(ctx, listSquadMemberPreviewRowsBySquad, squadID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListSquadMemberPreviewRowsBySquadRow{} + for rows.Next() { + var i ListSquadMemberPreviewRowsBySquadRow + if err := rows.Scan( + &i.SquadID, + &i.MemberType, + &i.MemberID, + &i.Role, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listSquadMemberStatusRows = `-- name: ListSquadMemberStatusRows :many SELECT sm.id AS squad_member_id, diff --git a/server/pkg/db/queries/squad.sql b/server/pkg/db/queries/squad.sql index 97e9bae46..cc740f497 100644 --- a/server/pkg/db/queries/squad.sql +++ b/server/pkg/db/queries/squad.sql @@ -12,6 +12,36 @@ SELECT * FROM squad WHERE id = $1 AND workspace_id = $2; -- name: ListSquads :many SELECT * FROM squad WHERE workspace_id = $1 AND archived_at IS NULL ORDER BY created_at ASC; +-- name: ListSquadMemberPreviewRows :many +-- Static squad membership summary for list/hover previews. This deliberately +-- excludes derived runtime/task status; the squad detail members-status +-- endpoint owns live state. +SELECT + sm.squad_id, + sm.member_type, + sm.member_id, + sm.role +FROM squad_member sm +JOIN squad s ON s.id = sm.squad_id +WHERE s.workspace_id = $1 AND s.archived_at IS NULL +ORDER BY + sm.squad_id ASC, + (sm.member_type = 'agent' AND sm.member_id = s.leader_id) DESC, + sm.created_at ASC; + +-- name: ListSquadMemberPreviewRowsBySquad :many +SELECT + sm.squad_id, + sm.member_type, + sm.member_id, + sm.role +FROM squad_member sm +JOIN squad s ON s.id = sm.squad_id +WHERE sm.squad_id = $1 +ORDER BY + (sm.member_type = 'agent' AND sm.member_id = s.leader_id) DESC, + sm.created_at ASC; + -- name: ListAllSquads :many SELECT * FROM squad WHERE workspace_id = $1 ORDER BY created_at ASC;