diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 6b5f19fc..24ad9e44 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -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(""); 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) => { 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"); diff --git a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx index e49d5403..1385f051 100644 --- a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx @@ -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 = { @@ -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("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"); diff --git a/apps/web/app/(dashboard)/settings/_components/repositories-tab.tsx b/apps/web/app/(dashboard)/settings/_components/repositories-tab.tsx index 4b352bd3..3ccef144 100644 --- a/apps/web/app/(dashboard)/settings/_components/repositories-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/repositories-tab.tsx @@ -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(workspace?.repos ?? []); diff --git a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx index 594e8c45..17988cf2 100644 --- a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx @@ -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); diff --git a/apps/web/components/common/mention-hover-card.tsx b/apps/web/components/common/mention-hover-card.tsx index a3986686..90f825d4 100644 --- a/apps/web/components/common/mention-hover-card.tsx +++ b/apps/web/components/common/mention-hover-card.tsx @@ -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 ( diff --git a/apps/web/core/runtimes/index.ts b/apps/web/core/runtimes/index.ts new file mode 100644 index 00000000..88f0524d --- /dev/null +++ b/apps/web/core/runtimes/index.ts @@ -0,0 +1 @@ +export { runtimeKeys, runtimeListOptions } from "./queries"; diff --git a/apps/web/core/runtimes/queries.ts b/apps/web/core/runtimes/queries.ts new file mode 100644 index 00000000..3cfb5aa4 --- /dev/null +++ b/apps/web/core/runtimes/queries.ts @@ -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 }), + }); +} diff --git a/apps/web/core/workspace/index.ts b/apps/web/core/workspace/index.ts new file mode 100644 index 00000000..e5b264c2 --- /dev/null +++ b/apps/web/core/workspace/index.ts @@ -0,0 +1,13 @@ +export { + workspaceKeys, + workspaceListOptions, + memberListOptions, + agentListOptions, + skillListOptions, +} from "./queries"; + +export { + useCreateWorkspace, + useLeaveWorkspace, + useDeleteWorkspace, +} from "./mutations"; diff --git a/apps/web/core/workspace/mutations.ts b/apps/web/core/workspace/mutations.ts new file mode 100644 index 00000000..caf9cce6 --- /dev/null +++ b/apps/web/core/workspace/mutations.ts @@ -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() }); + }, + }); +} diff --git a/apps/web/core/workspace/queries.ts b/apps/web/core/workspace/queries.ts new file mode 100644 index 00000000..e5f054d4 --- /dev/null +++ b/apps/web/core/workspace/queries.ts @@ -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(), + }); +} diff --git a/apps/web/features/editor/extensions/mention-suggestion.tsx b/apps/web/features/editor/extensions/mention-suggestion.tsx index 38680133..4c15204a 100644 --- a/apps/web/features/editor/extensions/mention-suggestion.tsx +++ b/apps/web/features/editor/extensions/mention-suggestion.tsx @@ -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(issueKeys.list(wsId))?.issues ?? [] : []; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 20b9d7e3..50247ba7 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -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; diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index cdd25813..cdd95147 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -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), diff --git a/apps/web/features/issues/components/pickers/assignee-picker.tsx b/apps/web/features/issues/components/pickers/assignee-picker.tsx index 3bc3a70f..fa34b4a0 100644 --- a/apps/web/features/issues/components/pickers/assignee-picker.tsx +++ b/apps/web/features/issues/components/pickers/assignee-picker.tsx @@ -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); diff --git a/apps/web/features/modals/create-issue.tsx b/apps/web/features/modals/create-issue.tsx index 9ba26404..0fbf7744 100644 --- a/apps/web/features/modals/create-issue.tsx +++ b/apps/web/features/modals/create-issue.tsx @@ -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 | 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); diff --git a/apps/web/features/my-issues/components/my-issues-page.tsx b/apps/web/features/my-issues/components/my-issues-page.tsx index dbdaac93..b1fd926f 100644 --- a/apps/web/features/my-issues/components/my-issues-page.tsx +++ b/apps/web/features/my-issues/components/my-issues-page.tsx @@ -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); diff --git a/apps/web/features/realtime/use-realtime-sync.ts b/apps/web/features/realtime/use-realtime-sync.ts index dd231892..8b744c65 100644 --- a/apps/web/features/realtime/use-realtime-sync.ts +++ b/apps/web/features/realtime/use-realtime-sync.ts @@ -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>(); @@ -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); } diff --git a/apps/web/features/runtimes/components/runtimes-page.tsx b/apps/web/features/runtimes/components/runtimes-page.tsx index 7a87ee21..65f54222 100644 --- a/apps/web/features/runtimes/components/runtimes-page.tsx +++ b/apps/web/features/runtimes/components/runtimes-page.tsx @@ -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() { > diff --git a/apps/web/features/runtimes/index.ts b/apps/web/features/runtimes/index.ts index c24959ba..5fa5b0bf 100644 --- a/apps/web/features/runtimes/index.ts +++ b/apps/web/features/runtimes/index.ts @@ -1,2 +1 @@ export { RuntimesPage } from "./components"; -export { useRuntimeStore } from "./store"; diff --git a/apps/web/features/runtimes/store.ts b/apps/web/features/runtimes/store.ts deleted file mode 100644 index 03e9f716..00000000 --- a/apps/web/features/runtimes/store.ts +++ /dev/null @@ -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; - setSelectedId: (id: string) => void; - /** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */ - patchRuntime: (id: string, updates: Partial) => void; - /** Replace the full runtimes list (used on daemon:register events). */ - setRuntimes: (runtimes: AgentRuntime[]) => void; -} - -type RuntimeStore = RuntimeState & RuntimeActions; - -export const useRuntimeStore = create((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 ?? "", - }); - }, -})); diff --git a/apps/web/features/skills/components/skills-page.tsx b/apps/web/features/skills/components/skills-page.tsx index 645d7428..d2428d40 100644 --- a/apps/web/features/skills/components/skills-page.tsx +++ b/apps/web/features/skills/components/skills-page.tsx @@ -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; onDelete: (id: string) => Promise; }) { + 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(""); 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"); diff --git a/apps/web/features/workspace/hooks.ts b/apps/web/features/workspace/hooks.ts index b1e062d9..b0513e90 100644 --- a/apps/web/features/workspace/hooks.ts +++ b/apps/web/features/workspace/hooks.ts @@ -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); diff --git a/apps/web/features/workspace/store.ts b/apps/web/features/workspace/store.ts index 142a658a..4d7ab3b2 100644 --- a/apps/web/features/workspace/store.ts +++ b/apps/web/features/workspace/store.ts @@ -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; - switchWorkspace: (workspaceId: string) => Promise; + ) => Workspace | null; + switchWorkspace: (workspaceId: string) => void; refreshWorkspaces: () => Promise; - refreshMembers: () => Promise; - updateAgent: (id: string, updates: Partial) => void; - refreshAgents: () => Promise; - refreshSkills: () => Promise; - upsertSkill: (skill: Skill) => void; - removeSkill: (id: string) => void; + updateWorkspace: (ws: Workspace) => void; createWorkspace: (data: { name: string; slug: string; description?: string; }) => Promise; - updateWorkspace: (ws: Workspace) => void; leaveWorkspace: (workspaceId: string) => Promise; deleteWorkspace: (workspaceId: string) => Promise; clearWorkspace: () => void; @@ -47,12 +37,9 @@ export const useWorkspaceStore = create((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((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((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((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((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((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: [] }); }, })); diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx index 94988fe1..ccbe8356 100644 --- a/apps/web/test/helpers.tsx +++ b/apps/web/test/helpers.tsx @@ -85,8 +85,6 @@ export const mockAuthValue: Record = { 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";