refactor(issues): migrate all consumers to TanStack Query (Phase 1, Commits 5-10)

- Migrate issue-detail.tsx: useQuery for issue data, useUpdateIssue/useDeleteIssue
- Migrate issues-page.tsx, my-issues-page.tsx, board-card.tsx: useQuery for list
- Migrate batch-action-toolbar.tsx, create-issue.tsx: mutation hooks
- Migrate edge consumers: mention-suggestion, mention-view, agents page, issue-mention-card
- Remove Zustand writes from WS sync (TQ cache is now sole source of truth)
- Remove useIssueStore.fetch() dependency from workspace store
- Gut useIssueStore to client-only: { activeIssueId, setActiveIssue }
- Update test wrappers with QueryClientProvider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-07 15:46:08 +08:00
parent 7560f7be85
commit 1ad057fb0f
14 changed files with 116 additions and 190 deletions

View file

@ -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<AgentTask[]>([]);
const [loading, setLoading] = useState(true);
const issues = useIssueStore((s) => s.issues);
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
useEffect(() => {
setLoading(true);

View file

@ -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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
describe("IssuesPage", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -302,17 +309,18 @@ describe("IssuesPage", () => {
it("shows loading state initially", () => {
mockStoreState.loading = true;
mockStoreState.issues = [];
render(<IssuesPage />);
renderWithQuery(<IssuesPage />);
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(<IssuesPage />);
renderWithQuery(<IssuesPage />);
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(<IssuesPage />);
renderWithQuery(<IssuesPage />);
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(<IssuesPage />);
renderWithQuery(<IssuesPage />);
expect(screen.getByText("Issues")).toBeInTheDocument();
await screen.findByText("Issues");
});
it("shows scope buttons", () => {
it("shows scope buttons", async () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
render(<IssuesPage />);
renderWithQuery(<IssuesPage />);
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(<IssuesPage />);
renderWithQuery(<IssuesPage />);
// 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(<IssuesPage />);
renderWithQuery(<IssuesPage />);
// Should still render the board/list view, not a "no issues" message
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();

View file

@ -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<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
: [];
const q = query.toLowerCase();
// Show "All members" option when query is empty or matches "all"

View file

@ -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();

View file

@ -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<UpdateIssueRequest>) => {
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);
}
};

View file

@ -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<UpdateIssueRequest>) => {
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;

View file

@ -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<string | null>(null);
const didHighlightRef = useRef<string | null>(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<string | null>(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<UpdateIssueRequest>) => {
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<ContentEditorRef>(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");

View file

@ -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 (

View file

@ -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) {

View file

@ -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<void>;
setIssues: (issues: Issue[]) => void;
addIssue: (issue: Issue) => void;
updateIssue: (id: string, updates: Partial<Issue>) => void;
removeIssue: (id: string) => void;
setActiveIssue: (id: string | null) => void;
}
export const useIssueStore = create<IssueState>((set, get) => ({
issues: [],
loading: true,
export const useIssueStore = create<IssueClientState>((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 }),
}));

View file

@ -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) => (

View file

@ -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) {

View file

@ -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(),

View file

@ -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<WorkspaceStore>((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<WorkspaceStore>((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: [] });