Improve board and squad hover cards (#3188)

This commit is contained in:
Naiyuan Qing
2026-05-25 12:58:39 +08:00
committed by GitHub
parent 077bc055f7
commit 6261ea45fd
11 changed files with 487 additions and 171 deletions

View File

@@ -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<Squad[]> {
return this.fetch(`/api/squads`);
const raw = await this.fetch<unknown>(`/api/squads`);
return parseWithFallback(raw, SquadListSchema, EMPTY_SQUAD_LIST, {
endpoint: "GET /api/squads",
}) as Squad[];
}
async getSquad(id: string): Promise<Squad> {
return this.fetch(`/api/squads/${id}`);
const raw = await this.fetch<unknown>(`/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<Squad> {
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
const raw = await this.fetch<unknown>("/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<Squad> {
return this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
const raw = await this.fetch<unknown>(`/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<void> {

View File

@@ -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.

View File

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

View File

@@ -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<StoreApi<AuthState>>;
}
@@ -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);
},
};

View File

@@ -114,6 +114,7 @@ export type {
Squad,
SquadMember,
SquadMemberType,
SquadMemberPreview,
SquadActivityLog,
SquadActivityOutcome,
CreateSquadRequest,

View File

@@ -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 {

View File

@@ -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 ? (
<span className="inline-flex items-center gap-1.5 min-w-0">
<span className="flex min-w-0 max-w-full items-center gap-1.5">
<ActorAvatar
actorType={issue.assignee_type!}
actorId={issue.assignee_id!}
size={20}
enableHoverCard
className="shrink-0"
/>
{assigneeName && (
<span className="text-xs text-foreground truncate">{assigneeName}</span>
<span className="min-w-0 truncate text-xs text-foreground">{assigneeName}</span>
)}
</span>
) : 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 (
<div className="rounded-lg border-[0.5px] border-border bg-card py-3 px-2.5 shadow-[0_3px_6px_-2px_rgba(0,0,0,0.02),0_1px_1px_0_rgba(0,0,0,0.04)] transition-colors group-hover/card:border-accent group-hover/card:bg-accent group-data-[popup-open]/card:border-accent group-data-[popup-open]/card:bg-accent">
@@ -221,74 +222,82 @@ export const BoardCardContent = memo(function BoardCardContent({
{/* Meta row: assignee (left), start date, due date, child progress (right) */}
{showMetaRow && (
<div className="mt-2 flex items-center gap-2">
{assigneeNode}
{showStartDate && (
editable ? (
<PickerWrapper className="shrink-0">
<StartDatePicker
startDate={issue.start_date}
onUpdate={handleUpdate}
trigger={
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="size-3" />
{formatDate(issue.start_date!)}
</span>
}
/>
</PickerWrapper>
) : (
<span className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="size-3" />
{formatDate(issue.start_date!)}
</span>
)
)}
{showDueDate && (
editable ? (
<PickerWrapper className="shrink-0">
<DueDatePicker
dueDate={issue.due_date}
onUpdate={handleUpdate}
trigger={
<span
className={`flex items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
</span>
}
/>
</PickerWrapper>
) : (
<span
className={`flex shrink-0 items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
</span>
)
)}
{showChildProgress && (
<div className="ml-auto inline-flex shrink-0 items-center gap-1">
<ProgressRing done={childProgress!.done} total={childProgress!.total} size={14} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{childProgress!.done}/{childProgress!.total}
</span>
<div className="mt-2 flex items-center justify-between gap-2">
{showAssignee && (
<div className="min-w-0 flex-1">
{assigneeNode}
</div>
)}
{metaIsAssigneeOnly && (
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{t(($) => $.card.updated_ago, { time: timeAgo(issue.updated_at) })}
</span>
{showRightMeta && (
<div className="ml-auto flex shrink-0 items-center gap-2">
{showStartDate && (
editable ? (
<PickerWrapper className="shrink-0">
<StartDatePicker
startDate={issue.start_date}
onUpdate={handleUpdate}
trigger={
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="size-3" />
{formatDate(issue.start_date!)}
</span>
}
/>
</PickerWrapper>
) : (
<span className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="size-3" />
{formatDate(issue.start_date!)}
</span>
)
)}
{showDueDate && (
editable ? (
<PickerWrapper className="shrink-0">
<DueDatePicker
dueDate={issue.due_date}
onUpdate={handleUpdate}
trigger={
<span
className={`flex items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
</span>
}
/>
</PickerWrapper>
) : (
<span
className={`flex shrink-0 items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
</span>
)
)}
{showChildProgress && (
<div className="inline-flex shrink-0 items-center gap-1">
<ProgressRing done={childProgress!.done} total={childProgress!.total} size={14} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{childProgress!.done}/{childProgress!.total}
</span>
</div>
)}
{showUpdatedHint && (
<span className="shrink-0 text-xs text-muted-foreground">
{t(($) => $.card.updated_ago, { time: timeAgo(issue.updated_at) })}
</span>
)}
</div>
)}
</div>
)}

View File

@@ -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<string, string> = {
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 (
<div className="group flex flex-col gap-3 text-left">
@@ -116,9 +99,10 @@ export function SquadProfileCard({ squadId }: SquadProfileCardProps) {
</p>
)}
{leaderFirst.length > 0 && (
{memberCount > 0 && (
<MembersList
members={leaderFirst}
members={memberPreview}
memberCount={memberCount}
leaderId={squad.leader_id}
agents={agents}
wsMembers={wsMembers}
@@ -130,11 +114,13 @@ export function SquadProfileCard({ squadId }: SquadProfileCardProps) {
function MembersList({
members,
memberCount,
leaderId,
agents,
wsMembers,
}: {
members: SquadMemberStatus[];
members: SquadMemberPreview[];
memberCount: number;
leaderId: string;
agents: { id: string; name: string }[];
wsMembers: { user_id: string; name: string; role: string }[];
@@ -142,15 +128,15 @@ function MembersList({
const { t } = useT("squads");
const p = useWorkspacePaths();
const visible = members.slice(0, 3);
const overflow = members.length - visible.length;
const overflow = Math.max(0, memberCount - visible.length);
return (
<div className="flex flex-col gap-1.5 text-xs">
<span className="text-muted-foreground">
{t(($) => $.profile_card.members_section)}
<span className="ml-1 tabular-nums">· {members.length}</span>
<span className="ml-1 tabular-nums">· {memberCount}</span>
</span>
<div className="flex max-h-[132px] flex-col gap-0.5 overflow-y-auto">
<div className="flex flex-col gap-0.5">
{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({
<AppLink
key={`${m.member_type}-${m.member_id}`}
href={href}
className="-mx-1 flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-accent"
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/60"
>
<ActorAvatar
actorType={m.member_type}
@@ -196,22 +168,14 @@ function MembersList({
showStatusDot={m.member_type === "agent"}
className="shrink-0"
/>
<span className="min-w-0 truncate font-medium">{name}</span>
<span className="min-w-0 flex-1 truncate font-medium">{name}</span>
{isLeader && (
<span className="shrink-0 rounded-md bg-amber-100 px-1 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<span className="max-w-[4rem] shrink-0 truncate rounded-md bg-amber-100 px-1 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{t(($) => $.members_tab.leader_chip)}
</span>
)}
{m.member_type === "agent" && statusLabel && (
<span className="ml-auto inline-flex shrink-0 items-center gap-1 text-muted-foreground">
<span
className={`h-1.5 w-1.5 rounded-full ${statusDotClass ?? "bg-muted-foreground/40"}`}
/>
{statusLabel}
</span>
)}
{m.member_type === "member" && memberRole && (
<span className="ml-auto shrink-0 text-muted-foreground">
<span className="max-w-[3.5rem] shrink-0 truncate text-muted-foreground">
{memberRole}
</span>
)}
@@ -219,7 +183,7 @@ function MembersList({
);
})}
{overflow > 0 && (
<span className="text-muted-foreground">
<span className="px-2 py-0.5 text-muted-foreground">
{t(($) => $.profile_card.more_members, { count: overflow })}
</span>
)}

View File

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

View File

@@ -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,

View File

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