feat(core): migrate workspace + runtimes to TanStack Query (Phase 3+4)

- Create core/workspace/ with queries (members, agents, skills, list) and mutations
- Create core/runtimes/ with queries
- Migrate 11 consumer files from useWorkspaceStore.members/agents/skills to useQuery
- Replace all WS refreshMap entries with qc.invalidateQueries
- Simplify workspace store: delete members/agents/skills fields + refresh methods,
  hydrateWorkspace becomes synchronous (TQ auto-fetches on component mount)
- Delete useRuntimeStore (no consumers left), runtimes-page uses local useState + TQ
- Remove workspace→runtime cross-store dependency
- Clean up dead test helper mocks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-07 17:19:52 +08:00
parent 1d812bd446
commit e40341ab73
24 changed files with 251 additions and 289 deletions

View file

@ -75,10 +75,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useRuntimeStore } from "@/features/runtimes";
import { useQuery } from "@tanstack/react-query";
import { runtimeListOptions } from "@core/runtimes/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWorkspaceId } from "@core/hooks";
import { issueListOptions } from "@core/issues/queries";
import { skillListOptions, agentListOptions, workspaceKeys } from "@core/workspace/queries";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
@ -445,8 +446,9 @@ function SkillsTab({
}: {
agent: Agent;
}) {
const workspaceSkills = useWorkspaceStore((s) => s.skills);
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const [saving, setSaving] = useState(false);
const [showPicker, setShowPicker] = useState(false);
@ -458,7 +460,7 @@ function SkillsTab({
try {
const newIds = [...agent.skills.map((s) => s.id), skillId];
await api.setAgentSkills(agent.id, { skill_ids: newIds });
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add skill");
} finally {
@ -472,7 +474,7 @@ function SkillsTab({
try {
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
await api.setAgentSkills(agent.id, { skill_ids: newIds });
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
} finally {
@ -1547,21 +1549,17 @@ function AgentDetail({
export default function AgentsPage() {
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
const agents = useWorkspaceStore((s) => s.agents);
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const [selectedId, setSelectedId] = useState<string>("");
const [showArchived, setShowArchived] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const runtimes = useRuntimeStore((s) => s.runtimes);
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_agents_layout",
});
useEffect(() => {
if (workspace) fetchRuntimes();
}, [workspace, fetchRuntimes]);
const filteredAgents = useMemo(
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
[agents, showArchived],
@ -1578,14 +1576,14 @@ export default function AgentsPage() {
const handleCreate = async (data: CreateAgentRequest) => {
const agent = await api.createAgent(data);
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
setSelectedId(agent.id);
};
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
try {
await api.updateAgent(id, data as UpdateAgentRequest);
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
toast.success("Agent updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update agent");
@ -1596,7 +1594,7 @@ export default function AgentsPage() {
const handleArchive = async (id: string) => {
try {
await api.archiveAgent(id);
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
toast.success("Agent archived");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
@ -1606,7 +1604,7 @@ export default function AgentsPage() {
const handleRestore = async (id: string) => {
try {
await api.restoreAgent(id);
await refreshAgents();
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
toast.success("Agent restored");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to restore agent");

View file

@ -36,8 +36,11 @@ import {
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, workspaceKeys } from "@core/workspace/queries";
import { api } from "@/shared/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
@ -140,8 +143,9 @@ function MemberRow({
export function MembersTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
@ -168,7 +172,7 @@ export function MembersTab() {
});
setInviteEmail("");
setInviteRole("member");
await refreshMembers();
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
toast.success("Member added");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add member");
@ -182,7 +186,7 @@ export function MembersTab() {
setMemberActionId(memberId);
try {
await api.updateMember(workspace.id, memberId, { role });
await refreshMembers();
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
toast.success("Role updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update member");
@ -201,7 +205,7 @@ export function MembersTab() {
setMemberActionId(member.id);
try {
await api.deleteMember(workspace.id, member.id);
await refreshMembers();
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
toast.success("Member removed");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove member");

View file

@ -6,15 +6,19 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions } from "@core/workspace/queries";
import { api } from "@/shared/api";
import type { WorkspaceRepo } from "@/shared/types";
export function RepositoriesTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);

View file

@ -18,14 +18,18 @@ import {
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions } from "@core/workspace/queries";
import { api } from "@/shared/api";
export function WorkspaceTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);

View file

@ -2,9 +2,11 @@
import type { ReactNode } from "react";
import { Users } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
interface MentionHoverCardProps {
type: string;
@ -13,8 +15,9 @@ interface MentionHoverCardProps {
}
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
if (type === "all") {
return (

View file

@ -0,0 +1 @@
export { runtimeKeys, runtimeListOptions } from "./queries";

View file

@ -0,0 +1,14 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
export const runtimeKeys = {
all: (wsId: string) => ["runtimes", wsId] as const,
list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const,
};
export function runtimeListOptions(wsId: string) {
return queryOptions({
queryKey: runtimeKeys.list(wsId),
queryFn: () => api.listRuntimes({ workspace_id: wsId }),
});
}

View file

@ -0,0 +1,13 @@
export {
workspaceKeys,
workspaceListOptions,
memberListOptions,
agentListOptions,
skillListOptions,
} from "./queries";
export {
useCreateWorkspace,
useLeaveWorkspace,
useDeleteWorkspace,
} from "./mutations";

View file

@ -0,0 +1,34 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import { workspaceKeys } from "./queries";
export function useCreateWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; slug: string; description?: string }) =>
api.createWorkspace(data),
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
}
export function useLeaveWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId),
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
}
export function useDeleteWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId),
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
}

View file

@ -0,0 +1,39 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
export const workspaceKeys = {
all: (wsId: string) => ["workspaces", wsId] as const,
list: () => ["workspaces", "list"] as const,
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
};
export function workspaceListOptions() {
return queryOptions({
queryKey: workspaceKeys.list(),
queryFn: () => api.listWorkspaces(),
});
}
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 skillListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.skills(wsId),
queryFn: () => api.listSkills(),
});
}

View file

@ -13,7 +13,8 @@ import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { useWorkspaceStore } from "@/features/workspace";
import { getQueryClient } from "@core/query-client";
import { issueKeys } from "@core/issues/queries";
import type { Issue, ListIssuesResponse } from "@/shared/types";
import { workspaceKeys } from "@core/workspace/queries";
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@/shared/types";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon } from "@/features/issues/components/status-icon";
import { Badge } from "@/components/ui/badge";
@ -218,8 +219,9 @@ export function createMentionSuggestion(): Omit<
> {
return {
items: ({ query }) => {
const { members, agents } = useWorkspaceStore.getState();
const wsId = useWorkspaceStore.getState().workspace?.id;
const members: MemberWithUser[] = wsId ? getQueryClient().getQueryData(workspaceKeys.members(wsId)) ?? [] : [];
const agents: Agent[] = wsId ? getQueryClient().getQueryData(workspaceKeys.agents(wsId)) ?? [] : [];
const issues: Issue[] = wsId
? getQueryClient().getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
: [];

View file

@ -68,6 +68,7 @@ import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { issueListOptions, issueDetailOptions } from "@core/issues/queries";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { useUpdateIssue, useDeleteIssue } from "@core/issues/mutations";
import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
@ -177,12 +178,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const router = useRouter();
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
// Issue navigation — read from TQ list cache
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
const currentIndex = allIssues.findIndex((i) => i.id === id);
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;

View file

@ -43,7 +43,9 @@ import {
PRIORITY_CONFIG,
} from "@/features/issues/config";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { useWorkspaceStore } from "@/features/workspace";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { ActorAvatar } from "@/components/common/actor-avatar";
import {
useIssueViewStore,
@ -155,8 +157,9 @@ function ActorSubContent({
noAssigneeCount?: number;
}) {
const [search, setSearch] = useState("");
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const query = search.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),

View file

@ -3,8 +3,11 @@
import { useState } from "react";
import { Lock, UserMinus } from "lucide-react";
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useActorName } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { ActorAvatar } from "@/components/common/actor-avatar";
import {
PropertyPicker,
@ -44,8 +47,9 @@ export function AssigneePicker({
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const currentMember = members.find((m) => m.user_id === user?.id);

View file

@ -30,6 +30,9 @@ import { TitleEditor } from "@/features/editor";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import { useCreateIssue } from "@core/issues/mutations";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
@ -67,8 +70,9 @@ function PillButton({
export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
const router = useRouter();
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const draft = useIssueDraftStore((s) => s.draft);

View file

@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, WorkspaceAvatar } from "@/features/workspace";
import { useQuery } from "@tanstack/react-query";
import { agentListOptions } from "@core/workspace/queries";
import { filterIssues } from "@/features/issues/utils/filter";
import { BOARD_STATUSES } from "@/features/issues/config";
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
@ -26,8 +27,8 @@ import { MyIssuesHeader } from "./my-issues-header";
export function MyIssuesPage() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);

View file

@ -7,7 +7,6 @@ import { toast } from "sonner";
import { useWorkspaceStore } from "@/features/workspace";
import { useAuthStore } from "@/features/auth";
import { createLogger } from "@/shared/logger";
import { api } from "@/shared/api";
import { issueKeys } from "@core/issues/queries";
import {
onIssueCreated,
@ -16,6 +15,7 @@ import {
} from "@core/issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "@core/inbox/ws-updaters";
import { inboxKeys } from "@core/inbox/queries";
import { workspaceKeys } from "@core/workspace/queries";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
@ -55,22 +55,21 @@ export function useRealtimeSync(ws: WSClient | null) {
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) onInboxInvalidate(qc, wsId);
},
agent: () => void useWorkspaceStore.getState().refreshAgents(),
member: () => void useWorkspaceStore.getState().refreshMembers(),
workspace: () => {
// Lightweight: only re-fetch workspace list, don't hydrate everything.
// workspace:deleted is handled by a precise side-effect handler below.
api.listWorkspaces().then((wsList) => {
const current = useWorkspaceStore.getState().workspace;
const updated = current
? wsList.find((w) => w.id === current.id)
: null;
if (updated) useWorkspaceStore.getState().updateWorkspace(updated);
}).catch((err) => {
logger.error("workspace refresh failed", err);
});
agent: () => {
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
},
member: () => {
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
},
workspace: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
skill: () => {
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
},
skill: () => void useWorkspaceStore.getState().refreshSkills(),
};
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@ -191,12 +190,11 @@ export function useRealtimeSync(ws: WSClient | null) {
if (wsId) {
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
}
await Promise.all([
useWorkspaceStore.getState().refreshAgents(),
useWorkspaceStore.getState().refreshMembers(),
useWorkspaceStore.getState().refreshSkills(),
]);
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
} catch (e) {
logger.error("reconnect refetch failed", e);
}

View file

@ -1,8 +1,9 @@
"use client";
import { useEffect, useCallback } from "react";
import { useState, useCallback } from "react";
import { Server } from "lucide-react";
import { useDefaultLayout } from "react-resizable-panels";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ResizablePanelGroup,
ResizablePanel,
@ -10,38 +11,35 @@ import {
} from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { runtimeListOptions, runtimeKeys } from "@core/runtimes/queries";
import { useWSEvent } from "@/features/realtime";
import { useRuntimeStore } from "../store";
import { RuntimeList } from "./runtime-list";
import { RuntimeDetail } from "./runtime-detail";
export default function RuntimesPage() {
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
const runtimes = useRuntimeStore((s) => s.runtimes);
const selectedId = useRuntimeStore((s) => s.selectedId);
const fetching = useRuntimeStore((s) => s.fetching);
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
const setSelectedId = useRuntimeStore((s) => s.setSelectedId);
const wsId = useWorkspaceId();
const qc = useQueryClient();
const { data: runtimes = [], isLoading: fetching } = useQuery(runtimeListOptions(wsId));
const [selectedId, setSelectedId] = useState("");
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_runtimes_layout",
});
useEffect(() => {
if (workspace) fetchRuntimes();
}, [workspace, fetchRuntimes]);
// Re-fetch on daemon register/deregister events.
// Heartbeat events are not broadcast over WS, so no handler needed.
const handleDaemonEvent = useCallback(() => {
fetchRuntimes();
}, [fetchRuntimes]);
qc.invalidateQueries({ queryKey: runtimeKeys.list(wsId) });
}, [qc, wsId]);
useWSEvent("daemon:register", handleDaemonEvent);
const selected = runtimes.find((r) => r.id === selectedId) ?? null;
// Auto-select first runtime if nothing selected
const effectiveSelectedId = selectedId && runtimes.some((r) => r.id === selectedId)
? selectedId
: runtimes[0]?.id ?? "";
const selected = runtimes.find((r) => r.id === effectiveSelectedId) ?? null;
if (isLoading || fetching) {
return (
@ -95,7 +93,7 @@ export default function RuntimesPage() {
>
<RuntimeList
runtimes={runtimes}
selectedId={selectedId}
selectedId={effectiveSelectedId}
onSelect={setSelectedId}
/>
</ResizablePanel>

View file

@ -1,2 +1 @@
export { RuntimesPage } from "./components";
export { useRuntimeStore } from "./store";

View file

@ -1,70 +0,0 @@
"use client";
import { create } from "zustand";
import type { AgentRuntime } from "@/shared/types";
import { api } from "@/shared/api";
import { useWorkspaceStore } from "@/features/workspace";
interface RuntimeState {
runtimes: AgentRuntime[];
selectedId: string;
fetching: boolean;
}
interface RuntimeActions {
fetchRuntimes: () => Promise<void>;
setSelectedId: (id: string) => void;
/** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */
patchRuntime: (id: string, updates: Partial<AgentRuntime>) => void;
/** Replace the full runtimes list (used on daemon:register events). */
setRuntimes: (runtimes: AgentRuntime[]) => void;
}
type RuntimeStore = RuntimeState & RuntimeActions;
export const useRuntimeStore = create<RuntimeStore>((set, get) => ({
// State
runtimes: [],
selectedId: "",
fetching: true,
// Actions
fetchRuntimes: async () => {
const workspace = useWorkspaceStore.getState().workspace;
if (!workspace) return;
try {
const data = await api.listRuntimes({ workspace_id: workspace.id });
const { selectedId } = get();
set({
runtimes: data,
fetching: false,
// Auto-select first if nothing selected
selectedId: selectedId && data.some((r) => r.id === selectedId)
? selectedId
: data[0]?.id ?? "",
});
} catch {
set({ fetching: false });
}
},
setSelectedId: (id) => set({ selectedId: id }),
patchRuntime: (id, updates) => {
set((state) => ({
runtimes: state.runtimes.map((r) =>
r.id === id ? { ...r, ...updates } : r,
),
}));
},
setRuntimes: (runtimes) => {
const { selectedId } = get();
set({
runtimes,
selectedId: selectedId && runtimes.some((r) => r.id === selectedId)
? selectedId
: runtimes[0]?.id ?? "",
});
},
}));

View file

@ -33,8 +33,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/shared/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { skillListOptions, workspaceKeys } from "@core/workspace/queries";
import { FileTree } from "./file-tree";
import { FileViewer } from "./file-viewer";
@ -346,6 +348,8 @@ function SkillDetail({
onUpdate: (id: string, data: UpdateSkillRequest) => Promise<void>;
onDelete: (id: string) => Promise<void>;
}) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [name, setName] = useState(skill.name);
const [description, setDescription] = useState(skill.description);
const [content, setContent] = useState(skill.content);
@ -370,12 +374,12 @@ function SkillDetail({
setSelectedPath(SKILL_MD);
setLoadingFiles(true);
api.getSkill(skill.id).then((full) => {
useWorkspaceStore.getState().upsertSkill(full);
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
}).catch((e) => {
toast.error(e instanceof Error ? e.message : "Failed to load skill files");
}).finally(() => setLoadingFiles(false));
}, [skill.id]);
}, [skill.id, qc, wsId]);
// Build the virtual file map
const fileMap = useMemo(() => buildFileMap(content, files), [content, files]);
@ -610,10 +614,9 @@ function SkillDetail({
export default function SkillsPage() {
const isLoading = useAuthStore((s) => s.isLoading);
const skills = useWorkspaceStore((s) => s.skills);
const refreshSkills = useWorkspaceStore((s) => s.refreshSkills);
const upsertSkill = useWorkspaceStore((s) => s.upsertSkill);
const removeSkill = useWorkspaceStore((s) => s.removeSkill);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: skills = [] } = useQuery(skillListOptions(wsId));
const [selectedId, setSelectedId] = useState<string>("");
const [showCreate, setShowCreate] = useState(false);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
@ -628,22 +631,22 @@ export default function SkillsPage() {
const handleCreate = async (data: CreateSkillRequest) => {
const skill = await api.createSkill(data);
upsertSkill(skill);
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
setSelectedId(skill.id);
toast.success("Skill created");
};
const handleImport = async (url: string) => {
const skill = await api.importSkill({ url });
upsertSkill(skill);
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
setSelectedId(skill.id);
toast.success("Skill imported");
};
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
try {
const updated = await api.updateSkill(id, data);
upsertSkill(updated);
await api.updateSkill(id, data);
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
toast.success("Skill saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save skill");
@ -658,7 +661,7 @@ export default function SkillsPage() {
const remaining = skills.filter((s) => s.id !== id);
setSelectedId(remaining[0]?.id ?? "");
}
removeSkill(id);
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
toast.success("Skill deleted");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete skill");

View file

@ -1,10 +1,13 @@
"use client";
import { useWorkspaceStore } from "./store";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
export function useActorName() {
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const getMemberName = (userId: string) => {
const m = members.find((m) => m.user_id === userId);

View file

@ -1,8 +1,7 @@
"use client";
import { create } from "zustand";
import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
import { useRuntimeStore } from "@/features/runtimes";
import type { Workspace } from "@/shared/types";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
@ -12,30 +11,21 @@ const logger = createLogger("workspace-store");
interface WorkspaceState {
workspace: Workspace | null;
workspaces: Workspace[];
members: MemberWithUser[];
agents: Agent[];
skills: Skill[];
}
interface WorkspaceActions {
hydrateWorkspace: (
wsList: Workspace[],
preferredWorkspaceId?: string | null,
) => Promise<Workspace | null>;
switchWorkspace: (workspaceId: string) => Promise<void>;
) => Workspace | null;
switchWorkspace: (workspaceId: string) => void;
refreshWorkspaces: () => Promise<Workspace[]>;
refreshMembers: () => Promise<void>;
updateAgent: (id: string, updates: Partial<Agent>) => void;
refreshAgents: () => Promise<void>;
refreshSkills: () => Promise<void>;
upsertSkill: (skill: Skill) => void;
removeSkill: (id: string) => void;
updateWorkspace: (ws: Workspace) => void;
createWorkspace: (data: {
name: string;
slug: string;
description?: string;
}) => Promise<Workspace>;
updateWorkspace: (ws: Workspace) => void;
leaveWorkspace: (workspaceId: string) => Promise<void>;
deleteWorkspace: (workspaceId: string) => Promise<void>;
clearWorkspace: () => void;
@ -47,12 +37,9 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
// State
workspace: null,
workspaces: [],
members: [],
agents: [],
skills: [],
// Actions
hydrateWorkspace: async (wsList, preferredWorkspaceId) => {
hydrateWorkspace: (wsList, preferredWorkspaceId) => {
set({ workspaces: wsList });
const nextWorkspace =
@ -65,53 +52,35 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
if (!nextWorkspace) {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, members: [], agents: [], skills: [] });
set({ workspace: null });
return null;
}
api.setWorkspaceId(nextWorkspace.id);
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
set({ workspace: nextWorkspace });
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
api.listMembers(nextWorkspace.id).catch((e) => {
logger.error("failed to load members", e);
toast.error("Failed to load members");
return [] as MemberWithUser[];
}),
api.listAgents({ workspace_id: nextWorkspace.id, include_archived: true }).catch((e) => {
logger.error("failed to load agents", e);
toast.error("Failed to load agents");
return [] as Agent[];
}),
api.listSkills().catch(() => [] as Skill[]),
]);
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
// Members, agents, skills, issues, inbox are all managed by TanStack Query.
// They auto-fetch when components mount with the workspace ID in their query key.
return nextWorkspace;
},
switchWorkspace: async (workspaceId) => {
switchWorkspace: (workspaceId) => {
logger.info("switching to", workspaceId);
const { workspaces, hydrateWorkspace } = get();
const ws = workspaces.find((item) => item.id === workspaceId);
if (!ws) return;
// Switch identity FIRST — api client, localStorage, and the
// workspace object in this store — so that any in-flight refetch
// (e.g. triggered by a WS event during the async gap) already
// targets the new workspace.
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
// Clear stale data across stores before hydrating.
// Issue + inbox caches are managed by TanStack Query (keyed by wsId, auto-refetches).
useRuntimeStore.getState().setRuntimes([]);
set({ workspace: ws, members: [], agents: [], skills: [] });
// All data caches (issues, inbox, members, agents, skills, runtimes)
// are managed by TanStack Query, keyed by wsId — auto-refetch on switch.
set({ workspace: ws });
await hydrateWorkspace(workspaces, ws.id);
hydrateWorkspace(workspaces, ws.id);
},
refreshWorkspaces: async () => {
@ -119,7 +88,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
try {
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
} catch (e) {
logger.error("failed to refresh workspaces", e);
@ -128,77 +97,6 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
}
},
refreshMembers: async () => {
const { workspace } = get();
if (!workspace) return;
try {
const members = await api.listMembers(workspace.id);
set({ members });
} catch (e) {
logger.error("failed to refresh members", e);
toast.error("Failed to refresh members");
}
},
updateAgent: (id, updates) =>
set((s) => ({
agents: s.agents.map((a) => (a.id === id ? { ...a, ...updates } : a)),
})),
refreshAgents: async () => {
const { workspace } = get();
if (!workspace) return;
try {
const agents = await api.listAgents({ workspace_id: workspace.id, include_archived: true });
set({ agents });
} catch (e) {
logger.error("failed to refresh agents", e);
toast.error("Failed to refresh agents");
}
},
refreshSkills: async () => {
const { workspace, skills: existing } = get();
if (!workspace) return;
try {
const fetched = await api.listSkills();
// listSkills doesn't include files — preserve files from existing entries
const filesById = new Map(
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
);
const merged = fetched.map((s) => ({
...s,
files: s.files ?? filesById.get(s.id) ?? [],
}));
set({ skills: merged });
} catch (e) {
logger.error("failed to refresh skills", e);
toast.error("Failed to refresh skills");
}
},
upsertSkill: (skill) => {
set((state) => {
const idx = state.skills.findIndex((s) => s.id === skill.id);
if (idx >= 0) {
const next = [...state.skills];
next[idx] = skill;
return { skills: next };
}
return { skills: [...state.skills, skill] };
});
},
removeSkill: (id) => {
set((state) => ({ skills: state.skills.filter((s) => s.id !== id) }));
},
createWorkspace: async (data) => {
const ws = await api.createWorkspace(data);
set((state) => ({ workspaces: [...state.workspaces, ws] }));
return ws;
},
updateWorkspace: (ws) => {
set((state) => ({
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
@ -208,13 +106,19 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
}));
},
createWorkspace: async (data) => {
const ws = await api.createWorkspace(data);
set((state) => ({ workspaces: [...state.workspaces, ws] }));
return ws;
},
leaveWorkspace: async (workspaceId) => {
await api.leaveWorkspace(workspaceId);
const { workspace, hydrateWorkspace } = get();
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
await hydrateWorkspace(wsList, preferredWorkspaceId);
hydrateWorkspace(wsList, preferredWorkspaceId);
},
deleteWorkspace: async (workspaceId) => {
@ -223,11 +127,11 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
await hydrateWorkspace(wsList, preferredWorkspaceId);
hydrateWorkspace(wsList, preferredWorkspaceId);
},
clearWorkspace: () => {
api.setWorkspaceId(null);
set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] });
set({ workspace: null, workspaces: [] });
},
}));

View file

@ -85,8 +85,6 @@ export const mockAuthValue: Record<string, any> = {
leaveWorkspace: vi.fn(),
deleteWorkspace: vi.fn(),
refreshWorkspaces: vi.fn(),
refreshMembers: vi.fn(),
refreshAgents: vi.fn(),
getMemberName: (userId: string) => {
const m = mockMembers.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";