diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 8a9680c9..6b5f19fc 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -76,7 +76,9 @@ import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; import { useRuntimeStore } from "@/features/runtimes"; -import { useIssueStore } from "@/features/issues"; +import { useQuery } from "@tanstack/react-query"; +import { useWorkspaceId } from "@core/hooks"; +import { issueListOptions } from "@core/issues/queries"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { useFileUpload } from "@/shared/hooks/use-file-upload"; @@ -1056,7 +1058,8 @@ function TriggersTab({ function TasksTab({ agent }: { agent: Agent }) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); - const issues = useIssueStore((s) => s.issues); + const wsId = useWorkspaceId(); + const { data: issues = [] } = useQuery(issueListOptions(wsId)); useEffect(() => { setLoading(true); diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index c48dc4f8..d307602d 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@/shared/types"; // Mock next/navigation @@ -61,10 +62,11 @@ vi.mock("sonner", () => ({ // Mock api const mockUpdateIssue = vi.fn(); +const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 })); vi.mock("@/shared/api", () => ({ api: { - listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }), + listIssues: (...args: any[]) => mockListIssues(...args), updateIssue: (...args: any[]) => mockUpdateIssue(...args), }, })); @@ -282,6 +284,11 @@ const mockIssues: Issue[] = [ import IssuesPage from "./page"; +function renderWithQuery(ui: React.ReactElement) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } }); + return render({ui}); +} + describe("IssuesPage", () => { beforeEach(() => { vi.clearAllMocks(); @@ -302,17 +309,18 @@ describe("IssuesPage", () => { it("shows loading state initially", () => { mockStoreState.loading = true; mockStoreState.issues = []; - render(); + renderWithQuery(); expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true); }); it("renders issues in board view after loading", async () => { mockStoreState.loading = false; mockStoreState.issues = mockIssues; + mockListIssues.mockResolvedValue({ issues: mockIssues, total: mockIssues.length }); - render(); + renderWithQuery(); - expect(screen.getByText("Implement auth")).toBeInTheDocument(); + await screen.findByText("Implement auth"); expect(screen.getByText("Design landing page")).toBeInTheDocument(); expect(screen.getByText("Write tests")).toBeInTheDocument(); }); @@ -320,43 +328,46 @@ describe("IssuesPage", () => { it("renders board columns", async () => { mockStoreState.loading = false; mockStoreState.issues = mockIssues; + mockListIssues.mockResolvedValue({ issues: mockIssues, total: mockIssues.length }); - render(); + renderWithQuery(); - expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1); + await screen.findByText("Backlog"); expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1); }); - it("shows workspace breadcrumb", () => { + it("shows workspace breadcrumb", async () => { mockStoreState.loading = false; mockStoreState.issues = []; - render(); + renderWithQuery(); - expect(screen.getByText("Issues")).toBeInTheDocument(); + await screen.findByText("Issues"); }); - it("shows scope buttons", () => { + it("shows scope buttons", async () => { mockStoreState.loading = false; mockStoreState.issues = []; - render(); + renderWithQuery(); - expect(screen.getByText("All")).toBeInTheDocument(); + await screen.findByText("All"); expect(screen.getByText("Members")).toBeInTheDocument(); expect(screen.getByText("Agents")).toBeInTheDocument(); }); - it("shows filter and display icon buttons", () => { + it("shows filter and display icon buttons", async () => { mockStoreState.loading = false; mockStoreState.issues = mockIssues; + mockListIssues.mockResolvedValue({ issues: mockIssues, total: mockIssues.length }); - render(); + renderWithQuery(); - // Filter and Display are now icon-only buttons, verify they render as buttons + // Wait for query to resolve and component to render past loading state + await screen.findByText("Implement auth"); const buttons = screen.getAllByRole("button"); expect(buttons.length).toBeGreaterThan(0); }); @@ -365,7 +376,7 @@ describe("IssuesPage", () => { mockStoreState.loading = false; mockStoreState.issues = []; - render(); + renderWithQuery(); // Should still render the board/list view, not a "no issues" message expect(screen.queryByText("No matching issues")).not.toBeInTheDocument(); diff --git a/apps/web/features/editor/extensions/mention-suggestion.tsx b/apps/web/features/editor/extensions/mention-suggestion.tsx index 67e65346..38680133 100644 --- a/apps/web/features/editor/extensions/mention-suggestion.tsx +++ b/apps/web/features/editor/extensions/mention-suggestion.tsx @@ -11,7 +11,9 @@ import { import { ReactRenderer } from "@tiptap/react"; import { computePosition, offset, flip, shift } from "@floating-ui/dom"; import { useWorkspaceStore } from "@/features/workspace"; -import { useIssueStore } from "@/features/issues"; +import { getQueryClient } from "@core/query-client"; +import { issueKeys } from "@core/issues/queries"; +import type { Issue, ListIssuesResponse } from "@/shared/types"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { StatusIcon } from "@/features/issues/components/status-icon"; import { Badge } from "@/components/ui/badge"; @@ -217,7 +219,10 @@ export function createMentionSuggestion(): Omit< return { items: ({ query }) => { const { members, agents } = useWorkspaceStore.getState(); - const { issues } = useIssueStore.getState(); + const wsId = useWorkspaceStore.getState().workspace?.id; + const issues: Issue[] = wsId + ? getQueryClient().getQueryData(issueKeys.list(wsId))?.issues ?? [] + : []; const q = query.toLowerCase(); // Show "All members" option when query is empty or matches "all" diff --git a/apps/web/features/editor/extensions/mention-view.tsx b/apps/web/features/editor/extensions/mention-view.tsx index 0f62158d..b36be469 100644 --- a/apps/web/features/editor/extensions/mention-view.tsx +++ b/apps/web/features/editor/extensions/mention-view.tsx @@ -20,7 +20,9 @@ import { NodeViewWrapper } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react"; -import { useIssueStore } from "@/features/issues/store"; +import { useQuery } from "@tanstack/react-query"; +import { issueListOptions } from "@core/issues/queries"; +import { useWorkspaceId } from "@core/hooks"; import { StatusIcon } from "@/features/issues/components/status-icon"; export function MentionView({ node }: NodeViewProps) { @@ -48,7 +50,9 @@ function IssueMention({ issueId: string; fallbackLabel?: string; }) { - const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId)); + const wsId = useWorkspaceId(); + const { data: issues = [] } = useQuery(issueListOptions(wsId)); + const issue = issues.find((i) => i.id === issueId); const handleClick = (e: React.MouseEvent) => { e.preventDefault(); diff --git a/apps/web/features/issues/components/batch-action-toolbar.tsx b/apps/web/features/issues/components/batch-action-toolbar.tsx index 9b259e3e..4246433c 100644 --- a/apps/web/features/issues/components/batch-action-toolbar.tsx +++ b/apps/web/features/issues/components/batch-action-toolbar.tsx @@ -21,9 +21,8 @@ import { } from "@/components/ui/popover"; import type { UpdateIssueRequest } from "@/shared/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; -import { useIssueStore } from "@/features/issues/store"; import { useIssueSelectionStore } from "@/features/issues/stores/selection-store"; -import { api } from "@/shared/api"; +import { useBatchUpdateIssues, useBatchDeleteIssues } from "@core/issues/mutations"; import { StatusIcon } from "./status-icon"; import { PriorityIcon } from "./priority-icon"; import { AssigneePicker } from "./pickers"; @@ -37,46 +36,31 @@ export function BatchActionToolbar() { const [priorityOpen, setPriorityOpen] = useState(false); const [assigneeOpen, setAssigneeOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); - const [loading, setLoading] = useState(false); + const batchUpdate = useBatchUpdateIssues(); + const batchDelete = useBatchDeleteIssues(); + const loading = batchUpdate.isPending || batchDelete.isPending; if (count === 0) return null; const ids = Array.from(selectedIds); const handleBatchUpdate = async (updates: Partial) => { - setLoading(true); try { - await api.batchUpdateIssues(ids, updates); - for (const id of ids) { - useIssueStore.getState().updateIssue(id, updates); - } + await batchUpdate.mutateAsync({ ids, updates }); toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`); } catch { toast.error("Failed to update issues"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); - } finally { - setLoading(false); } }; const handleBatchDelete = async () => { - setLoading(true); try { - await api.batchDeleteIssues(ids); - for (const id of ids) { - useIssueStore.getState().removeIssue(id); - } + await batchDelete.mutateAsync(ids); clear(); toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`); } catch { toast.error("Failed to delete issues"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); } finally { - setLoading(false); setDeleteOpen(false); } }; diff --git a/apps/web/features/issues/components/board-card.tsx b/apps/web/features/issues/components/board-card.tsx index 25f4610b..c2dae07e 100644 --- a/apps/web/features/issues/components/board-card.tsx +++ b/apps/web/features/issues/components/board-card.tsx @@ -8,8 +8,7 @@ import { toast } from "sonner"; import type { Issue, UpdateIssueRequest } from "@/shared/types"; import { CalendarDays } from "lucide-react"; import { ActorAvatar } from "@/components/common/actor-avatar"; -import { api } from "@/shared/api"; -import { useIssueStore } from "@/features/issues/store"; +import { useUpdateIssue } from "@core/issues/mutations"; import { PriorityIcon } from "./priority-icon"; import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers"; import { PRIORITY_CONFIG } from "@/features/issues/config"; @@ -46,16 +45,15 @@ export const BoardCardContent = memo(function BoardCardContent({ const storeProperties = useViewStore((s) => s.cardProperties); const priorityCfg = PRIORITY_CONFIG[issue.priority]; + const updateIssueMutation = useUpdateIssue(); const handleUpdate = useCallback( (updates: Partial) => { - const prev = { ...issue }; - useIssueStore.getState().updateIssue(issue.id, updates); - api.updateIssue(issue.id, updates).catch(() => { - useIssueStore.getState().updateIssue(issue.id, prev); - toast.error("Failed to update issue"); - }); + updateIssueMutation.mutate( + { id: issue.id, ...updates }, + { onError: () => toast.error("Failed to update issue") }, + ); }, - [issue], + [issue.id, updateIssueMutation], ); const showPriority = storeProperties.priority; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 43c38885..20b9d7e3 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -63,10 +63,12 @@ import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent import { CommentCard } from "./comment-card"; import { CommentInput } from "./comment-input"; import { AgentLiveCard, TaskRunHistory } from "./agent-live-card"; -import { api } from "@/shared/api"; +import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; -import { useIssueStore } from "@/features/issues"; +import { useWorkspaceId } from "@core/hooks"; +import { issueListOptions, issueDetailOptions } from "@core/issues/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"; import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers"; @@ -179,8 +181,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const agents = useWorkspaceStore((s) => s.agents); const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role; - // Issue navigation - const allIssues = useIssueStore((s) => s.issues); + // Issue navigation — read from TQ list cache + const wsId = useWorkspaceId(); + const { data: allIssues = [] } = useQuery(issueListOptions(wsId)); const currentIndex = allIssues.findIndex((i) => i.id === id); const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null; const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null; @@ -200,38 +203,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const [highlightedId, setHighlightedId] = useState(null); const didHighlightRef = useRef(null); - // Single source of truth: read issue directly from global store - const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null; - const [issueLoading, setIssueLoading] = useState(!issue); - - // If issue isn't in the store yet, fetch and upsert it. - // loadedIdRef tracks which issue was already loaded — if it disappears - // from the store (workspace switch clears all issues), skip refetch. - const loadedIdRef = useRef(null); - useEffect(() => { - if (issue) { - loadedIdRef.current = id; - setIssueLoading(false); - return; - } - // Issue was loaded for this id but vanished → store cleared (workspace switch) - if (loadedIdRef.current === id) { - loadedIdRef.current = null; - return; - } - // Issue not in store → fetch it - setIssueLoading(true); - api - .getIssue(id) - .then((iss) => { - useIssueStore.getState().addIssue(iss); - }) - .catch((e) => { - console.error(e); - toast.error("Failed to load issue"); - }) - .finally(() => setIssueLoading(false)); - }, [id, !!issue]); + // Issue data from TQ — uses detail query, seeded from list cache if available + const { data: issue = null, isLoading: issueLoading } = useQuery({ + ...issueDetailOptions(wsId, id), + initialData: () => allIssues.find((i) => i.id === id), + }); // Custom hooks — encapsulate timeline, reactions, subscribers const { @@ -283,18 +259,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" }); }, []); - // Issue field updates — write directly to the global store (single source of truth) + // Issue field updates via TQ mutation (optimistic update + rollback in mutation hook) + const updateIssueMutation = useUpdateIssue(); const handleUpdateField = useCallback( (updates: Partial) => { if (!issue) return; - const prev = { ...issue }; - useIssueStore.getState().updateIssue(id, updates); - api.updateIssue(id, updates).catch(() => { - useIssueStore.getState().updateIssue(id, prev); - toast.error("Failed to update issue"); - }); + updateIssueMutation.mutate( + { id, ...updates }, + { onError: () => toast.error("Failed to update issue") }, + ); }, - [issue, id], + [issue, id, updateIssueMutation], ); const descEditorRef = useRef(null); @@ -303,11 +278,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo [uploadWithToast, id], ); + const deleteIssueMutation = useDeleteIssue(); const handleDelete = async () => { setDeleting(true); try { - await api.deleteIssue(issue!.id); - useIssueStore.getState().removeIssue(issue!.id); + await deleteIssueMutation.mutateAsync(issue!.id); toast.success("Issue deleted"); if (onDelete) onDelete(); else router.push("/issues"); diff --git a/apps/web/features/issues/components/issue-mention-card.tsx b/apps/web/features/issues/components/issue-mention-card.tsx index 9862fffe..f115c8d4 100644 --- a/apps/web/features/issues/components/issue-mention-card.tsx +++ b/apps/web/features/issues/components/issue-mention-card.tsx @@ -1,7 +1,9 @@ "use client"; import Link from "next/link"; -import { useIssueStore } from "@/features/issues/store"; +import { useQuery } from "@tanstack/react-query"; +import { issueListOptions } from "@core/issues/queries"; +import { useWorkspaceId } from "@core/hooks"; import { StatusIcon } from "./status-icon"; interface IssueMentionCardProps { @@ -11,7 +13,9 @@ interface IssueMentionCardProps { } export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) { - const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId)); + const wsId = useWorkspaceId(); + const { data: issues = [] } = useQuery(issueListOptions(wsId)); + const issue = issues.find((i) => i.id === issueId); if (!issue) { return ( diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx index aa070940..a73bac98 100644 --- a/apps/web/features/issues/components/issues-page.tsx +++ b/apps/web/features/issues/components/issues-page.tsx @@ -5,7 +5,7 @@ import { toast } from "sonner"; import { ChevronRight, ListTodo } from "lucide-react"; import type { IssueStatus } from "@/shared/types"; import { Skeleton } from "@/components/ui/skeleton"; -import { useIssueStore } from "@/features/issues/store"; +import { useQuery } from "@tanstack/react-query"; import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store"; import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store"; import { ViewStoreProvider } from "@/features/issues/stores/view-store-context"; @@ -13,7 +13,9 @@ import { filterIssues } from "@/features/issues/utils/filter"; import { BOARD_STATUSES } from "@/features/issues/config"; import { useWorkspaceStore } from "@/features/workspace"; import { WorkspaceAvatar } from "@/features/workspace"; -import { api } from "@/shared/api"; +import { useWorkspaceId } from "@core/hooks"; +import { issueListOptions } from "@core/issues/queries"; +import { useUpdateIssue } from "@core/issues/mutations"; import { useIssueSelectionStore } from "@/features/issues/stores/selection-store"; import { IssuesHeader } from "./issues-header"; import { BoardView } from "./board-view"; @@ -21,8 +23,8 @@ import { ListView } from "./list-view"; import { BatchActionToolbar } from "./batch-action-toolbar"; export function IssuesPage() { - const allIssues = useIssueStore((s) => s.issues); - const loading = useIssueStore((s) => s.loading); + const wsId = useWorkspaceId(); + const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); const workspace = useWorkspaceStore((s) => s.workspace); const scope = useIssuesScopeStore((s) => s.scope); const viewMode = useIssueViewStore((s) => s.viewMode); @@ -64,6 +66,7 @@ export function IssuesPage() { return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)); }, [visibleStatuses]); + const updateIssueMutation = useUpdateIssue(); const handleMoveIssue = useCallback( (issueId: string, newStatus: IssueStatus, newPosition?: number) => { // Auto-switch to manual sort so drag ordering is preserved @@ -78,16 +81,12 @@ export function IssuesPage() { }; if (newPosition !== undefined) updates.position = newPosition; - useIssueStore.getState().updateIssue(issueId, updates); - - api.updateIssue(issueId, updates).catch(() => { - toast.error("Failed to move issue"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); - }); + updateIssueMutation.mutate( + { id: issueId, ...updates }, + { onError: () => toast.error("Failed to move issue") }, + ); }, - [] + [updateIssueMutation], ); if (loading) { diff --git a/apps/web/features/issues/store.ts b/apps/web/features/issues/store.ts index 1e47b7d7..312ca9a6 100644 --- a/apps/web/features/issues/store.ts +++ b/apps/web/features/issues/store.ts @@ -1,57 +1,13 @@ "use client"; import { create } from "zustand"; -import type { Issue } from "@/shared/types"; -import { toast } from "sonner"; -import { api } from "@/shared/api"; -import { createLogger } from "@/shared/logger"; -const logger = createLogger("issue-store"); - -interface IssueState { - issues: Issue[]; - loading: boolean; +interface IssueClientState { activeIssueId: string | null; - fetch: () => Promise; - setIssues: (issues: Issue[]) => void; - addIssue: (issue: Issue) => void; - updateIssue: (id: string, updates: Partial) => void; - removeIssue: (id: string) => void; setActiveIssue: (id: string | null) => void; } -export const useIssueStore = create((set, get) => ({ - issues: [], - loading: true, +export const useIssueStore = create((set) => ({ activeIssueId: null, - - fetch: async () => { - logger.debug("fetch start"); - const isInitialLoad = get().issues.length === 0; - if (isInitialLoad) set({ loading: true }); - try { - const res = await api.listIssues({ limit: 200 }); - logger.info("fetched", res.issues.length, "issues"); - set({ issues: res.issues, loading: false }); - } catch (err) { - logger.error("fetch failed", err); - toast.error("Failed to load issues"); - if (isInitialLoad) set({ loading: false }); - } - }, - - setIssues: (issues) => set({ issues }), - addIssue: (issue) => - set((s) => ({ - issues: s.issues.some((i) => i.id === issue.id) - ? s.issues - : [...s.issues, issue], - })), - updateIssue: (id, updates) => - set((s) => ({ - issues: s.issues.map((i) => (i.id === id ? { ...i, ...updates } : i)), - })), - removeIssue: (id) => - set((s) => ({ issues: s.issues.filter((i) => i.id !== id) })), setActiveIssue: (id) => set({ activeIssueId: id }), })); diff --git a/apps/web/features/modals/create-issue.tsx b/apps/web/features/modals/create-issue.tsx index 8dbf47dd..9ba26404 100644 --- a/apps/web/features/modals/create-issue.tsx +++ b/apps/web/features/modals/create-issue.tsx @@ -30,9 +30,8 @@ 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 { useIssueStore } from "@/features/issues"; import { useIssueDraftStore } from "@/features/issues/stores/draft-store"; -import { api } from "@/shared/api"; +import { useCreateIssue } from "@core/issues/mutations"; import { useFileUpload } from "@/shared/hooks/use-file-upload"; import { FileUploadButton } from "@/components/common/file-upload-button"; import { ActorAvatar } from "@/components/common/actor-avatar"; @@ -125,11 +124,12 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? }; const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); }; + const createIssueMutation = useCreateIssue(); const handleSubmit = async () => { if (!title.trim() || submitting) return; setSubmitting(true); try { - const issue = await api.createIssue({ + const issue = await createIssueMutation.mutateAsync({ title: title.trim(), description: descEditorRef.current?.getMarkdown()?.trim() || undefined, status, @@ -139,7 +139,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? due_date: dueDate || undefined, attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined, }); - useIssueStore.getState().addIssue(issue); clearDraft(); onClose(); toast.custom((t) => ( 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 ac7b13ba..dbdaac93 100644 --- a/apps/web/features/my-issues/components/my-issues-page.tsx +++ b/apps/web/features/my-issues/components/my-issues-page.tsx @@ -8,7 +8,7 @@ import type { IssueStatus } from "@/shared/types"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, WorkspaceAvatar } from "@/features/workspace"; -import { useIssueStore } from "@/features/issues/store"; +import { useQuery } from "@tanstack/react-query"; import { filterIssues } from "@/features/issues/utils/filter"; import { BOARD_STATUSES } from "@/features/issues/config"; import { ViewStoreProvider } from "@/features/issues/stores/view-store-context"; @@ -17,7 +17,9 @@ import { BoardView } from "@/features/issues/components/board-view"; import { ListView } from "@/features/issues/components/list-view"; import { BatchActionToolbar } from "@/features/issues/components/batch-action-toolbar"; import { registerViewStoreForWorkspaceSync } from "@/features/issues/stores/view-store"; -import { api } from "@/shared/api"; +import { useWorkspaceId } from "@core/hooks"; +import { issueListOptions } from "@core/issues/queries"; +import { useUpdateIssue } from "@core/issues/mutations"; import { myIssuesViewStore } from "../stores/my-issues-view-store"; import { MyIssuesHeader } from "./my-issues-header"; @@ -25,8 +27,8 @@ export function MyIssuesPage() { const user = useAuthStore((s) => s.user); const workspace = useWorkspaceStore((s) => s.workspace); const agents = useWorkspaceStore((s) => s.agents); - const allIssues = useIssueStore((s) => s.issues); - const loading = useIssueStore((s) => s.loading); + const wsId = useWorkspaceId(); + const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode); const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters); @@ -105,6 +107,7 @@ export function MyIssuesPage() { return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)); }, [visibleStatuses]); + const updateIssueMutation = useUpdateIssue(); const handleMoveIssue = useCallback( (issueId: string, newStatus: IssueStatus, newPosition?: number) => { const viewState = myIssuesViewStore.getState(); @@ -118,16 +121,12 @@ export function MyIssuesPage() { }; if (newPosition !== undefined) updates.position = newPosition; - useIssueStore.getState().updateIssue(issueId, updates); - - api.updateIssue(issueId, updates).catch(() => { - toast.error("Failed to move issue"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); - }); + updateIssueMutation.mutate( + { id: issueId, ...updates }, + { onError: () => toast.error("Failed to move issue") }, + ); }, - [], + [updateIssueMutation], ); if (loading) { diff --git a/apps/web/features/realtime/use-realtime-sync.ts b/apps/web/features/realtime/use-realtime-sync.ts index da38f5ae..a21484a9 100644 --- a/apps/web/features/realtime/use-realtime-sync.ts +++ b/apps/web/features/realtime/use-realtime-sync.ts @@ -3,7 +3,6 @@ import { useEffect } from "react"; import type { WSClient } from "@/shared/api"; import { toast } from "sonner"; -import { useIssueStore } from "@/features/issues"; import { useInboxStore } from "@/features/inbox"; import { useWorkspaceStore } from "@/features/workspace"; import { useAuthStore } from "@/features/auth"; @@ -99,11 +98,9 @@ export function useRealtimeSync(ws: WSClient | null) { const unsubIssueUpdated = ws.on("issue:updated", (p) => { const { issue } = p as IssueUpdatedPayload; if (!issue?.id) return; - useIssueStore.getState().updateIssue(issue.id, issue); if (issue.status) { useInboxStore.getState().updateIssueStatus(issue.id, issue.status); } - // Dual-write: TanStack Query cache const wsId = useWorkspaceStore.getState().workspace?.id; if (wsId) onIssueUpdated(getQueryClient(), wsId, issue); }); @@ -111,8 +108,6 @@ export function useRealtimeSync(ws: WSClient | null) { const unsubIssueCreated = ws.on("issue:created", (p) => { const { issue } = p as IssueCreatedPayload; if (!issue) return; - useIssueStore.getState().addIssue(issue); - // Dual-write: TanStack Query cache const wsId = useWorkspaceStore.getState().workspace?.id; if (wsId) onIssueCreated(getQueryClient(), wsId, issue); }); @@ -120,8 +115,6 @@ export function useRealtimeSync(ws: WSClient | null) { const unsubIssueDeleted = ws.on("issue:deleted", (p) => { const { issue_id } = p as IssueDeletedPayload; if (!issue_id) return; - useIssueStore.getState().removeIssue(issue_id); - // Dual-write: TanStack Query cache const wsId = useWorkspaceStore.getState().workspace?.id; if (wsId) onIssueDeleted(getQueryClient(), wsId, issue_id); }); @@ -185,13 +178,11 @@ export function useRealtimeSync(ws: WSClient | null) { const unsub = ws.onReconnect(async () => { logger.info("reconnected, refetching all data"); try { - // Dual-write: invalidate TanStack Query caches const wsId = useWorkspaceStore.getState().workspace?.id; if (wsId) { getQueryClient().invalidateQueries({ queryKey: issueKeys.all(wsId) }); } await Promise.all([ - useIssueStore.getState().fetch(), useInboxStore.getState().fetch(), useWorkspaceStore.getState().refreshAgents(), useWorkspaceStore.getState().refreshMembers(), diff --git a/apps/web/features/workspace/store.ts b/apps/web/features/workspace/store.ts index 0c6f8523..477c3da3 100644 --- a/apps/web/features/workspace/store.ts +++ b/apps/web/features/workspace/store.ts @@ -2,7 +2,6 @@ import { create } from "zustand"; import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types"; -import { useIssueStore } from "@/features/issues"; import { useInboxStore } from "@/features/inbox"; import { useRuntimeStore } from "@/features/runtimes"; import { toast } from "sonner"; @@ -88,7 +87,6 @@ export const useWorkspaceStore = create((set, get) => ({ return [] as Agent[]; }), api.listSkills().catch(() => [] as Skill[]), - useIssueStore.getState().fetch().catch(() => {}), useInboxStore.getState().fetch().catch(() => {}), ]); logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length); @@ -110,8 +108,8 @@ export const useWorkspaceStore = create((set, get) => ({ api.setWorkspaceId(ws.id); localStorage.setItem("multica_workspace_id", ws.id); - // Clear ALL stale data across every store before hydrating. - useIssueStore.getState().setIssues([]); + // Clear stale data across stores before hydrating. + // Issue cache is managed by TanStack Query (keyed by wsId, auto-refetches). useInboxStore.getState().setItems([]); useRuntimeStore.getState().setRuntimes([]); set({ workspace: ws, members: [], agents: [], skills: [] });