mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 03:55:27 +02:00
* feat(squads): show member working status on squad detail page
Add a new GET /api/squads/{id}/members/status endpoint that returns each
member's derived working/idle/offline/unstable status, the issues each
agent is currently running, and the last observed activity timestamp.
The Squad detail page's Members tab consumes this snapshot to render a
status pill and an active-issue link next to each agent, with live
refresh wired through the existing task/agent/daemon WS events.
Human members are returned with status=null so the UI can keep them in
the same list without implying a presence signal. Archived agents stay
in the response and surface as offline rather than being filtered out.
Co-authored-by: multica-agent <github@multica.ai>
* fix(squads): address review feedback on member status endpoint
- i18n the "blocked" issue-status pill in squad members tab (was a
bare literal that failed `i18next/no-literal-string` lint).
- Treat any dispatched/running task as working, even when its
`agent_task_queue.issue_id` is NULL (chat / quick-create tasks).
The agent slot is occupied regardless of whether we can render an
issue link.
- Force `offline` for archived agents so they appear in the list
but never look like they're still on duty, matching the RFC
decision in MUL-2319.
- Include `workspaceKeys.squads` in the post-reconnect /
workspace-switch bulk invalidation so members-status recovers
after a disconnect during which task/runtime events were missed.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
137 lines
4.6 KiB
TypeScript
137 lines
4.6 KiB
TypeScript
import { queryOptions } from "@tanstack/react-query";
|
|
import { api } from "../api";
|
|
import type { Agent, Squad, Workspace } from "../types";
|
|
|
|
export const workspaceKeys = {
|
|
all: (wsId: string) => ["workspaces", wsId] as const,
|
|
list: () => ["workspaces", "list"] as const,
|
|
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
|
|
invitations: (wsId: string) => ["workspaces", wsId, "invitations"] as const,
|
|
myInvitations: () => ["invitations", "mine"] as const,
|
|
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
|
|
squads: (wsId: string) => ["workspaces", wsId, "squads"] as const,
|
|
// Per-squad member status. Lives under the workspace key tree so
|
|
// workspace switches naturally drop the cache, and so a broad
|
|
// `["workspaces", wsId, "squads"]` invalidation covers it.
|
|
squadMemberStatus: (wsId: string, squadId: string) =>
|
|
["workspaces", wsId, "squads", squadId, "members-status"] as const,
|
|
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
|
|
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
|
|
};
|
|
|
|
export function workspaceListOptions() {
|
|
return queryOptions({
|
|
queryKey: workspaceKeys.list(),
|
|
queryFn: () => api.listWorkspaces(),
|
|
});
|
|
}
|
|
|
|
/** Resolves the workspace whose slug matches, from the cached workspace list. */
|
|
export function workspaceBySlugOptions(slug: string) {
|
|
return queryOptions({
|
|
...workspaceListOptions(),
|
|
select: (list: Workspace[]) => list.find((w) => w.slug === slug) ?? null,
|
|
});
|
|
}
|
|
|
|
export function memberListOptions(wsId: string) {
|
|
return queryOptions({
|
|
queryKey: workspaceKeys.members(wsId),
|
|
queryFn: () => api.listMembers(wsId),
|
|
});
|
|
}
|
|
|
|
export function agentListOptions(wsId: string) {
|
|
return queryOptions({
|
|
queryKey: workspaceKeys.agents(wsId),
|
|
queryFn: () =>
|
|
api.listAgents({ workspace_id: wsId, include_archived: true }),
|
|
});
|
|
}
|
|
|
|
export function squadListOptions(wsId: string) {
|
|
return queryOptions<Squad[]>({
|
|
queryKey: workspaceKeys.squads(wsId),
|
|
queryFn: () => api.listSquads(),
|
|
enabled: !!wsId,
|
|
});
|
|
}
|
|
|
|
// Per-squad members status snapshot. The freshness signal is the WS task /
|
|
// agent / runtime invalidation wired in use-realtime-sync (which broadly
|
|
// invalidates `["workspaces", wsId, "squads"]`); the staleTime is a
|
|
// tab-focus safety net.
|
|
export function squadMemberStatusOptions(wsId: string, squadId: string) {
|
|
return queryOptions({
|
|
queryKey: workspaceKeys.squadMemberStatus(wsId, squadId),
|
|
queryFn: () => api.getSquadMemberStatus(squadId),
|
|
enabled: !!wsId && !!squadId,
|
|
staleTime: 30 * 1000,
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
}
|
|
|
|
export function skillListOptions(wsId: string) {
|
|
return queryOptions({
|
|
queryKey: workspaceKeys.skills(wsId),
|
|
queryFn: () => api.listSkills(),
|
|
});
|
|
}
|
|
|
|
export function skillDetailOptions(wsId: string, skillId: string) {
|
|
return queryOptions({
|
|
queryKey: [...workspaceKeys.skills(wsId), skillId] as const,
|
|
queryFn: () => api.getSkill(skillId),
|
|
enabled: !!skillId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds a `Map<skillId, Agent[]>` from the cached agent list. The server
|
|
* already returns each agent with its full skill list inline, so no extra
|
|
* request is needed — "which agents use skill X" is pure client-side fold.
|
|
*
|
|
* Exposed as a plain helper rather than a `queryOptions` with `select` so
|
|
* the Map's identity is stable across unrelated agent-cache rerenders —
|
|
* callers wrap this in `useMemo(..., [agents])` and only re-fold when the
|
|
* agent array identity actually changes. Previously this was `{ select }`,
|
|
* which returned a new Map every subscription tick and triggered cascading
|
|
* re-renders on every `agent:updated` WS event.
|
|
*/
|
|
export function selectSkillAssignments(
|
|
agents: Agent[] | undefined,
|
|
): Map<string, Agent[]> {
|
|
const map = new Map<string, Agent[]>();
|
|
if (!agents) return map;
|
|
for (const a of agents) {
|
|
if (a.archived_at) continue;
|
|
for (const s of a.skills ?? []) {
|
|
const existing = map.get(s.id);
|
|
if (existing) existing.push(a);
|
|
else map.set(s.id, [a]);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
export function invitationListOptions(wsId: string) {
|
|
return queryOptions({
|
|
queryKey: workspaceKeys.invitations(wsId),
|
|
queryFn: () => api.listWorkspaceInvitations(wsId),
|
|
});
|
|
}
|
|
|
|
export function myInvitationListOptions() {
|
|
return queryOptions({
|
|
queryKey: workspaceKeys.myInvitations(),
|
|
queryFn: () => api.listMyInvitations(),
|
|
});
|
|
}
|
|
|
|
export function assigneeFrequencyOptions(wsId: string) {
|
|
return queryOptions({
|
|
queryKey: workspaceKeys.assigneeFrequency(wsId),
|
|
queryFn: () => api.getAssigneeFrequency(),
|
|
});
|
|
}
|