mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
Improve board and squad hover cards (#3188)
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ export type {
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberType,
|
||||
SquadMemberPreview,
|
||||
SquadActivityLog,
|
||||
SquadActivityOutcome,
|
||||
CreateSquadRequest,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user