Phase 0-5 plan for migrating server state from Zustand to TanStack Query, extracting headless business logic to core/ directory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
51 KiB
TanStack Query Migration & Core Extraction Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Migrate all server state from Zustand to TanStack Query, extract headless business logic to core/ directory, preparing for monorepo extraction in Phase 6.
Architecture: Replace Zustand's dual role (server cache + client state) with TanStack Query for server state (queries + mutations + cache) and Zustand for client-only state (UI selections, filters, drafts). WebSocket events bridge to TanStack Query via queryClient.setQueryData / invalidateQueries. A new core/ directory under apps/web/ acts as the future packages/core/ incubator.
Tech Stack: TanStack Query v5, Zustand v5, React 19, Next.js 16, TypeScript strict mode
Current State Summary
Stores holding server data (to migrate)
| Store | Server State Fields | API Calls in Store |
|---|---|---|
useAuthStore |
user |
getMe, sendCode, verifyCode |
useWorkspaceStore |
workspace, workspaces[], members[], agents[], skills[] |
listWorkspaces, listMembers, listAgents, listSkills, createWorkspace, leaveWorkspace, deleteWorkspace |
useIssueStore |
issues[] |
listIssues |
useInboxStore |
items[] |
listInbox |
useRuntimeStore |
runtimes[] |
listRuntimes |
Custom hooks with embedded server state (to migrate)
| Hook | State | API Calls |
|---|---|---|
useIssueTimeline |
timeline[] via useState |
listTimeline, createComment, updateComment, deleteComment, addReaction, removeReaction |
useIssueReactions |
reactions[] via useState |
getIssue, addIssueReaction, removeIssueReaction |
useIssueSubscribers |
subscribers[] via useState |
listIssueSubscribers, subscribeToIssue, unsubscribeFromIssue |
Stores staying as-is (pure client state)
| Store | Purpose |
|---|---|
useModalStore |
Which modal is open |
useNavigationStore |
Last visited path (persisted) |
useIssueSelectionStore |
Multi-select checkbox state |
useIssueDraftStore |
New issue form draft (persisted) |
useIssuesScopeStore |
"all" / "members" / "agents" filter (persisted) |
useIssueViewStore |
Board/list mode, filters, sort (persisted) |
myIssuesViewStore |
My-issues view filters (persisted) |
Files with direct api.* mutation calls (25+ files)
These will be migrated to use useMutation hooks from core/.
Target Directory Structure (after Phase 5)
apps/web/
├── app/ # Routing layer (unchanged)
├── core/ # NEW: headless business logic
│ ├── api/ # ApiClient, WSClient (moved from shared/api/)
│ │ ├── client.ts
│ │ ├── ws-client.ts
│ │ └── index.ts
│ ├── types/ # Domain types (moved from shared/types/)
│ │ ├── issue.ts
│ │ ├── workspace.ts
│ │ ├── agent.ts
│ │ ├── events.ts
│ │ ├── comment.ts
│ │ ├── inbox.ts
│ │ ├── subscriber.ts
│ │ ├── attachment.ts
│ │ ├── activity.ts
│ │ ├── api.ts
│ │ └── index.ts
│ ├── auth/
│ │ └── store.ts # Zustand: { user, isLoading } (client-only)
│ ├── workspace/
│ │ ├── queries.ts # workspaceQueries, memberQueries, agentQueries, skillQueries
│ │ ├── mutations.ts # useCreateWorkspace, useLeaveWorkspace, useDeleteWorkspace, ...
│ │ └── store.ts # Zustand: { currentWorkspaceId } (client-only)
│ ├── issues/
│ │ ├── queries.ts # issueQueries, timelineQueries, reactionQueries, subscriberQueries
│ │ ├── mutations.ts # useCreateIssue, useUpdateIssue, useDeleteIssue, useBatchUpdate, ...
│ │ ├── store.ts # Zustand: { activeIssueId } (client-only)
│ │ └── config/ # status.ts, priority.ts (pure data, zero JSX)
│ │ ├── status.ts
│ │ ├── priority.ts
│ │ └── index.ts
│ ├── inbox/
│ │ ├── queries.ts # inboxQueries (dedup as select transform)
│ │ └── mutations.ts # useMarkRead, useArchive, useBatchMarkRead, ...
│ ├── runtimes/
│ │ ├── queries.ts # runtimeQueries, usageQueries, activityQueries
│ │ └── store.ts # Zustand: { selectedRuntimeId } (client-only)
│ ├── tasks/
│ │ └── queries.ts # taskQueries (active task, messages, task runs)
│ ├── settings/
│ │ ├── queries.ts # tokenQueries
│ │ └── mutations.ts # useUpdateMe, useCreatePAT, useRevokePAT, ...
│ ├── realtime/
│ │ └── sync.ts # WS event → queryClient.setQueryData / invalidateQueries
│ ├── query-client.ts # QueryClient factory
│ └── logger.ts # Logger utility
├── features/ # Web-specific UI + business components
│ ├── auth/
│ │ ├── initializer.tsx # AuthInitializer (simplified: no workspace hydration)
│ │ ├── auth-cookie.ts
│ │ └── index.ts
│ ├── workspace/
│ │ ├── hooks.ts # useActorName (reads from TQ cache now)
│ │ ├── components/
│ │ └── index.ts
│ ├── issues/
│ │ ├── stores/ # Client-only stores (view, scope, draft, selection)
│ │ ├── components/ # Board, list, detail, pickers, icons
│ │ ├── utils/ # filter.ts, sort.ts
│ │ └── index.ts
│ ├── inbox/ # (store deleted, only re-exports from core)
│ │ └── index.ts
│ ├── editor/ # Tiptap editor (contains JSX, stays in features)
│ ├── modals/ # Modal store + registry
│ ├── realtime/
│ │ ├── provider.tsx # WSProvider (simplified)
│ │ ├── hooks.ts # useWSEvent, useWSReconnect
│ │ └── index.ts
│ ├── runtimes/
│ │ ├── components/ # UI components
│ │ └── index.ts
│ ├── skills/ # Skill management UI
│ ├── my-issues/ # My-issues page + view store
│ ├── navigation/ # Navigation store (Next.js specific)
│ └── landing/ # Landing page (Web only)
├── components/ # Shared UI (future packages/ui/)
│ ├── ui/ # ~55 shadcn components
│ ├── common/ # actor-avatar, emoji-picker, etc.
│ ├── markdown/
│ └── theme-provider.tsx
├── hooks/ # Generic UI hooks
├── lib/ # cn() utility
└── shared/ # DELETED by end of Phase 5
Phase 0: Infrastructure Setup
Task 0.1: Install TanStack Query
Files:
- Modify:
apps/web/package.json
Step 1: Install packages
cd apps/web && pnpm add @tanstack/react-query @tanstack/react-query-devtools
Step 2: Verify installation
pnpm typecheck
Expected: PASS (no type errors from new deps)
Step 3: Commit
git add apps/web/package.json apps/web/pnpm-lock.yaml ../../pnpm-lock.yaml
git commit -m "chore(web): install @tanstack/react-query and devtools"
Task 0.2: Create core/ directory and query client
Files:
- Create:
apps/web/core/query-client.ts
Step 1: Create query client factory
import { QueryClient } from "@tanstack/react-query";
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// WS keeps data fresh — no automatic refetch on window focus
staleTime: Infinity,
// Keep unused cache for 10 minutes
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 1,
},
mutations: {
retry: false,
},
},
});
}
Design decisions:
staleTime: Infinity— WebSocket events handle invalidation, no polling needed.gcTime: 10min— Navigating away from a page and back within 10min uses cache.refetchOnWindowFocus: false— WS connection already keeps data current.- Factory function (not singleton) — SSR-safe, each request gets its own client.
Step 2: Run typecheck
pnpm typecheck
Step 3: Commit
git add apps/web/core/query-client.ts
git commit -m "feat(core): add TanStack Query client factory"
Task 0.3: Add tsconfig path alias for @core/
Files:
- Modify:
apps/web/tsconfig.json
Step 1: Add path alias
Add to compilerOptions.paths:
{
"paths": {
"@/*": ["./*"],
"@core/*": ["./core/*"]
}
}
Step 2: Run typecheck
pnpm typecheck
Step 3: Commit
git add apps/web/tsconfig.json
git commit -m "chore(web): add @core/* tsconfig path alias"
Task 0.4: Add QueryClientProvider to root layout
Files:
- Create:
apps/web/core/provider.tsx - Modify:
apps/web/app/layout.tsx
Step 1: Create provider component
"use client";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createQueryClient } from "@core/query-client";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(createQueryClient);
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Step 2: Wrap root layout
In apps/web/app/layout.tsx, add QueryProvider inside ThemeProvider, wrapping everything:
import { QueryProvider } from "@core/provider";
// In the JSX:
<ThemeProvider>
<QueryProvider>
<AuthInitializer>
<WSProvider>{children}</WSProvider>
</AuthInitializer>
<ModalRegistry />
<Toaster />
</QueryProvider>
</ThemeProvider>
Step 3: Run typecheck and dev server
pnpm typecheck
pnpm dev:web # Verify app loads, check devtools panel appears
Step 4: Commit
git add apps/web/core/provider.tsx apps/web/app/layout.tsx
git commit -m "feat(core): add QueryClientProvider to root layout"
Task 0.5: Create useWorkspaceId utility hook
Files:
- Create:
apps/web/core/hooks.ts
Step 1: Create hook
This hook reads the current workspace ID from the workspace store. All query keys will use this to scope data per workspace.
import { useWorkspaceStore } from "@/features/workspace";
/**
* Returns the current workspace ID.
* All workspace-scoped queries should use this in their query key.
*/
export function useWorkspaceId(): string | null {
return useWorkspaceStore((s) => s.workspace?.id ?? null);
}
Note: During Phase 3 (workspace migration), this will read from core/workspace/store.ts instead. For now it bridges to the existing store.
Step 2: Run typecheck
pnpm typecheck
Step 3: Commit
git add apps/web/core/hooks.ts
git commit -m "feat(core): add useWorkspaceId utility hook"
Phase 1: Issues Migration
The issues domain is the largest and most complex. It validates all patterns (queries, mutations, optimistic updates, WS sync, cache management) that the other domains will follow.
Task 1.1: Create issue query key factory and queryOptions
Files:
- Create:
apps/web/core/issues/queries.ts
Step 1: Write query definitions
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
export const issueKeys = {
all: ["issues"] as const,
lists: () => [...issueKeys.all, "list"] as const,
list: (workspaceId: string | null) =>
[...issueKeys.lists(), workspaceId] as const,
details: () => [...issueKeys.all, "detail"] as const,
detail: (id: string) => [...issueKeys.details(), id] as const,
timeline: (issueId: string) =>
[...issueKeys.all, "timeline", issueId] as const,
reactions: (issueId: string) =>
[...issueKeys.all, "reactions", issueId] as const,
subscribers: (issueId: string) =>
[...issueKeys.all, "subscribers", issueId] as const,
};
export function issueListOptions(workspaceId: string | null) {
return queryOptions({
queryKey: issueKeys.list(workspaceId),
queryFn: () => api.listIssues({ limit: 200 }),
select: (data) => data.issues,
enabled: !!workspaceId,
});
}
export function issueDetailOptions(id: string) {
return queryOptions({
queryKey: issueKeys.detail(id),
queryFn: () => api.getIssue(id),
});
}
export function issueTimelineOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.timeline(issueId),
queryFn: () => api.listTimeline(issueId),
});
}
export function issueReactionsOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.reactions(issueId),
queryFn: async () => {
const issue = await api.getIssue(issueId);
return issue.reactions ?? [];
},
});
}
export function issueSubscribersOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.subscribers(issueId),
queryFn: () => api.listIssueSubscribers(issueId),
});
}
Key design patterns:
- Query key factory — Hierarchical keys enable targeted invalidation.
invalidateQueries({ queryKey: issueKeys.all })invalidates everything;issueKeys.list(wsId)only invalidates the list for that workspace. queryOptions()— TanStack Query v5 helper that bundles queryKey + queryFn. Ensures type safety —useQuery(issueListOptions(wsId))infers the return type.enabled: !!workspaceId— Don't fetch if no workspace selected (avoids 400s during init).select— Transform response inline.issueListOptionsunwraps{ issues: Issue[] }→Issue[].
Step 2: Run typecheck
pnpm typecheck
Step 3: Commit
git add apps/web/core/issues/queries.ts
git commit -m "feat(core/issues): add query key factory and queryOptions"
Task 1.2: Create issue mutations
Files:
- Create:
apps/web/core/issues/mutations.ts
Step 1: Write mutation hooks
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import { issueKeys } from "./queries";
import type { Issue } from "@/shared/types";
import type { CreateIssueRequest, UpdateIssueRequest } from "@/shared/types";
export function useCreateIssue() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
onSuccess: (newIssue) => {
// Add to list cache directly (WS event also does this, but local is faster)
qc.setQueryData<Issue[]>(
issueKeys.list(newIssue.workspace_id),
(old) => (old ? [...old, newIssue] : [newIssue]),
);
},
});
}
export function useUpdateIssue() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
api.updateIssue(id, data),
onMutate: async ({ id, ...data }) => {
// Cancel in-flight fetches for this list
await qc.cancelQueries({ queryKey: issueKeys.lists() });
// Snapshot for rollback
const previousList = qc.getQueriesData<Issue[]>({
queryKey: issueKeys.lists(),
});
// Optimistic update: patch issue in all list caches
qc.setQueriesData<Issue[]>({ queryKey: issueKeys.lists() }, (old) =>
old?.map((i) => (i.id === id ? { ...i, ...data } : i)),
);
// Also update detail cache if it exists
qc.setQueryData<Issue>(issueKeys.detail(id), (old) =>
old ? { ...old, ...data } : old,
);
return { previousList };
},
onError: (_err, _vars, context) => {
// Rollback
if (context?.previousList) {
for (const [key, data] of context.previousList) {
if (data) qc.setQueryData(key, data);
}
}
},
onSettled: (_data, _err, { id }) => {
// Refetch to ensure consistency
qc.invalidateQueries({ queryKey: issueKeys.detail(id) });
},
});
}
export function useDeleteIssue() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.deleteIssue(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: issueKeys.lists() });
const previousList = qc.getQueriesData<Issue[]>({
queryKey: issueKeys.lists(),
});
qc.setQueriesData<Issue[]>({ queryKey: issueKeys.lists() }, (old) =>
old?.filter((i) => i.id !== id),
);
qc.removeQueries({ queryKey: issueKeys.detail(id) });
return { previousList };
},
onError: (_err, _id, context) => {
if (context?.previousList) {
for (const [key, data] of context.previousList) {
if (data) qc.setQueryData(key, data);
}
}
},
});
}
export function useBatchUpdateIssues() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
ids,
updates,
}: {
ids: string[];
updates: UpdateIssueRequest;
}) => api.batchUpdateIssues(ids, updates),
onMutate: async ({ ids, updates }) => {
await qc.cancelQueries({ queryKey: issueKeys.lists() });
const previousList = qc.getQueriesData<Issue[]>({
queryKey: issueKeys.lists(),
});
qc.setQueriesData<Issue[]>({ queryKey: issueKeys.lists() }, (old) =>
old?.map((i) => (ids.includes(i.id) ? { ...i, ...updates } : i)),
);
return { previousList };
},
onError: (_err, _vars, context) => {
if (context?.previousList) {
for (const [key, data] of context.previousList) {
if (data) qc.setQueryData(key, data);
}
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.lists() });
},
});
}
export function useBatchDeleteIssues() {
const qc = useQueryClient();
return useMutation({
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
onMutate: async (ids) => {
await qc.cancelQueries({ queryKey: issueKeys.lists() });
const previousList = qc.getQueriesData<Issue[]>({
queryKey: issueKeys.lists(),
});
qc.setQueriesData<Issue[]>({ queryKey: issueKeys.lists() }, (old) =>
old?.filter((i) => !ids.includes(i.id)),
);
return { previousList };
},
onError: (_err, _ids, context) => {
if (context?.previousList) {
for (const [key, data] of context.previousList) {
if (data) qc.setQueryData(key, data);
}
}
},
});
}
Patterns:
- Optimistic update with rollback —
onMutatesaves snapshot, patches cache;onErrorrestores snapshot. setQueriesData(plural) — Updates all matching caches (e.g. if two components have different list queries).onSettledinvalidation — After mutation completes (success or failure), refetch to sync truth.
Step 2: Run typecheck
pnpm typecheck
Note: CreateIssueRequest and UpdateIssueRequest types may need to be defined or imported. Check shared/types/api.ts for existing request types. If they don't exist, define them in the same file or in shared/types/api.ts.
Step 3: Commit
git add apps/web/core/issues/mutations.ts
git commit -m "feat(core/issues): add mutation hooks with optimistic updates"
Task 1.3: Create issue client-only store
Files:
- Create:
apps/web/core/issues/store.ts
Step 1: Write minimal client store
import { create } from "zustand";
interface IssueClientState {
activeIssueId: string | null;
setActiveIssue: (id: string | null) => void;
}
export const useIssueClientStore = create<IssueClientState>((set) => ({
activeIssueId: null,
setActiveIssue: (id) => set({ activeIssueId: id }),
}));
This is everything that remains of useIssueStore after server state moves to TanStack Query.
Step 2: Commit
git add apps/web/core/issues/store.ts
git commit -m "feat(core/issues): add client-only store for activeIssueId"
Task 1.4: Create core/issues/index.ts barrel export
Files:
- Create:
apps/web/core/issues/index.ts
Step 1: Create barrel
export {
issueKeys,
issueListOptions,
issueDetailOptions,
issueTimelineOptions,
issueReactionsOptions,
issueSubscribersOptions,
} from "./queries";
export {
useCreateIssue,
useUpdateIssue,
useDeleteIssue,
useBatchUpdateIssues,
useBatchDeleteIssues,
} from "./mutations";
export { useIssueClientStore } from "./store";
Step 2: Commit
git add apps/web/core/issues/index.ts
git commit -m "feat(core/issues): add barrel export"
Task 1.5: Migrate issue timeline hook
Files:
- Modify:
apps/web/features/issues/hooks/use-issue-timeline.ts
Context: This hook currently manages its own useState<TimelineEntry[]> + manual useEffect fetching + manual WS subscription. Replace with useQuery + useMutation from core.
Step 1: Rewrite hook
Replace the entire hook to use TanStack Query:
useQuery(issueTimelineOptions(issueId))for initial data + cacheuseMutationforcreateComment,editComment,deleteComment,toggleReactionuseWSEventhandlers callqueryClient.setQueryDatato append/update entriesuseWSReconnectcallsqueryClient.invalidateQueries(replaces manual refetch)- Optimistic updates for comment CRUD and reactions
Key changes:
- Remove all
useStatefor timeline data - Remove all
useEffectfor data fetching - Remove manual WS → setState syncing
- Keep the public API shape the same so consumers don't change
Step 2: Update consumers
Check all imports of useIssueTimeline — the return type should be compatible. If it previously returned { timeline, loading, submitComment, ... }, the new version should return the same shape.
Step 3: Run typecheck + test
pnpm typecheck
pnpm test
Step 4: Commit
git add apps/web/features/issues/hooks/use-issue-timeline.ts
git commit -m "refactor(issues): migrate useIssueTimeline to TanStack Query"
Task 1.6: Migrate issue reactions hook
Files:
- Modify:
apps/web/features/issues/hooks/use-issue-reactions.ts
Step 1: Rewrite hook
Replace useState + useEffect + manual fetch with:
useQuery(issueReactionsOptions(issueId))useMutationfortoggleReaction(optimistic add/remove in cache)- WS events
issue_reaction:added/issue_reaction:removed→queryClient.setQueryData
Step 2: Run typecheck
pnpm typecheck
Step 3: Commit
git add apps/web/features/issues/hooks/use-issue-reactions.ts
git commit -m "refactor(issues): migrate useIssueReactions to TanStack Query"
Task 1.7: Migrate issue subscribers hook
Files:
- Modify:
apps/web/features/issues/hooks/use-issue-subscribers.ts
Step 1: Rewrite hook
Replace with:
useQuery(issueSubscribersOptions(issueId))useMutationfortoggleSubscriber(optimistic add/remove)- WS events
subscriber:added/subscriber:removed→queryClient.setQueryData
Step 2: Run typecheck
pnpm typecheck
Step 3: Commit
git add apps/web/features/issues/hooks/use-issue-subscribers.ts
git commit -m "refactor(issues): migrate useIssueSubscribers to TanStack Query"
Task 1.8: Migrate issue list consumers
Files to update (all read from useIssueStore):
apps/web/features/issues/components/issues-page.tsxapps/web/features/issues/components/board-view.tsxapps/web/features/issues/components/list-view.tsxapps/web/features/issues/components/board-card.tsxapps/web/features/issues/components/batch-action-toolbar.tsxapps/web/features/my-issues/components/my-issues-page.tsx
Step 1: Replace store reads with useQuery
In each file:
// Before
const issues = useIssueStore((s) => s.issues);
const loading = useIssueStore((s) => s.loading);
// After
import { useQuery } from "@tanstack/react-query";
import { issueListOptions } from "@core/issues";
import { useWorkspaceId } from "@core/hooks";
const workspaceId = useWorkspaceId();
const { data: issues = [], isLoading: loading } = useQuery(issueListOptions(workspaceId));
Step 2: Replace store mutations with mutation hooks
In each file that calls api.updateIssue, api.createIssue, etc.:
// Before
await api.updateIssue(id, updates);
useIssueStore.getState().updateIssue(id, updates);
// After
const updateIssue = useUpdateIssue();
await updateIssue.mutateAsync({ id, ...updates });
// Cache is updated automatically via onMutate optimistic update
Step 3: Replace activeIssueId
// Before
const activeIssueId = useIssueStore((s) => s.activeIssueId);
useIssueStore.getState().setActiveIssue(id);
// After
import { useIssueClientStore } from "@core/issues";
const activeIssueId = useIssueClientStore((s) => s.activeIssueId);
useIssueClientStore.getState().setActiveIssue(id);
Step 4: Run typecheck + test
pnpm typecheck
pnpm test
Step 5: Commit per file or group
git commit -m "refactor(issues): migrate issue list consumers to TanStack Query"
Task 1.9: Migrate issue detail component
Files:
- Modify:
apps/web/features/issues/components/issue-detail.tsx
Step 1: Replace local fetch with useQuery
// Before: useState + useEffect + api.getIssue(id)
// After:
const { data: issue, isLoading } = useQuery(issueDetailOptions(issueId));
Step 2: Replace mutation calls
// Before: api.updateIssue(id, updates) + manual state rollback
// After:
const updateIssue = useUpdateIssue();
const deleteIssue = useDeleteIssue();
// Optimistic update + rollback is handled by the mutation hook
Step 3: Run typecheck
pnpm typecheck
Step 4: Commit
git commit -m "refactor(issues): migrate issue-detail to TanStack Query"
Task 1.10: Migrate create issue modal
Files:
- Modify:
apps/web/features/modals/create-issue.tsx
Step 1: Replace direct api call with mutation
// Before: const issue = await api.createIssue(data);
// After:
const createIssue = useCreateIssue();
const issue = await createIssue.mutateAsync(data);
Step 2: Run typecheck
pnpm typecheck
Step 3: Commit
git commit -m "refactor(modals): migrate create-issue to useCreateIssue mutation"
Task 1.11: Update realtime sync for issues
Files:
- Modify:
apps/web/features/realtime/use-realtime-sync.ts
Step 1: Replace Zustand store writes with queryClient operations
// Before:
useIssueStore.getState().updateIssue(issue.id, issue);
useIssueStore.getState().addIssue(issue);
useIssueStore.getState().removeIssue(issue_id);
// After:
import { useQueryClient } from "@tanstack/react-query";
import { issueKeys } from "@core/issues";
// In the hook:
const queryClient = useQueryClient();
// issue:updated → patch cache directly
queryClient.setQueryData<Issue[]>(
issueKeys.list(workspaceId),
(old) => old?.map((i) => (i.id === issue.id ? { ...i, ...issue } : i)),
);
// Also update detail cache
queryClient.setQueryData<Issue>(
issueKeys.detail(issue.id),
(old) => old ? { ...old, ...issue } : old,
);
// issue:created → append to list cache
queryClient.setQueryData<Issue[]>(
issueKeys.list(workspaceId),
(old) => old && !old.some((i) => i.id === issue.id) ? [...old, issue] : old,
);
// issue:deleted → remove from list cache
queryClient.setQueryData<Issue[]>(
issueKeys.list(workspaceId),
(old) => old?.filter((i) => i.id !== issue_id),
);
queryClient.removeQueries({ queryKey: issueKeys.detail(issue_id) });
Step 2: Replace reconnect handler
// Before:
useIssueStore.getState().fetch();
// After:
queryClient.invalidateQueries({ queryKey: issueKeys.all });
Note: Keep the inbox/workspace/agent/member/skill handlers as-is for now. They'll be migrated in Phase 2-4.
Step 3: Run typecheck
pnpm typecheck
Step 4: Commit
git commit -m "refactor(realtime): migrate issue WS events to queryClient"
Task 1.12: Remove server state from useIssueStore
Files:
- Modify:
apps/web/features/issues/store.ts
Step 1: Strip server state and methods
Remove: issues, loading, fetch, setIssues, addIssue, updateIssue, removeIssue
Keep: activeIssueId, setActiveIssue
Or better: delete the file entirely if no one imports activeIssueId from it anymore (they should import from @core/issues).
Step 2: Update all remaining imports
Search for from "@/features/issues" that still reference useIssueStore for server data. Replace with @core/issues.
# Find all remaining useIssueStore imports
grep -rn "useIssueStore" apps/web/
Step 3: Remove the import from workspace store
In apps/web/features/workspace/store.ts, remove:
import { useIssueStore } from "@/features/issues";
// And all calls to useIssueStore.getState().fetch() / setIssues()
These are no longer needed — TanStack Query automatically fetches when the workspace ID changes in the query key.
Step 4: Run full check
make check
Step 5: Commit
git commit -m "refactor(issues): remove server state from useIssueStore"
Task 1.13: Move issues config to core
Files:
- Move:
apps/web/features/issues/config/status.ts→apps/web/core/issues/config/status.ts - Move:
apps/web/features/issues/config/priority.ts→apps/web/core/issues/config/priority.ts - Create:
apps/web/core/issues/config/index.ts
Step 1: Move files
Move the config files. These are pure data (no JSX, no react-dom), so they belong in core.
Important: Only move the data/constants. If these files export React components (like StatusIcon), keep the components in features/issues/components/ and only move the data objects.
Step 2: Update imports
grep -rn "from.*issues/config" apps/web/
Replace @/features/issues/config with @core/issues/config in all consumers.
Step 3: Run typecheck
pnpm typecheck
Step 4: Commit
git commit -m "refactor(core/issues): move status/priority config to core"
Phase 2: Inbox Migration
Task 2.1: Create inbox query key factory and queryOptions
Files:
- Create:
apps/web/core/inbox/queries.ts
Step 1: Write query definitions
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
import type { InboxItem } from "@/shared/types";
export const inboxKeys = {
all: ["inbox"] as const,
list: (workspaceId: string | null) =>
[...inboxKeys.all, "list", workspaceId] as const,
unreadCount: (workspaceId: string | null) =>
[...inboxKeys.all, "unread-count", workspaceId] as const,
};
/**
* Deduplicates inbox items by issue_id, keeping the latest entry.
* This was previously `useInboxStore.dedupedItems()`.
*/
function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
const map = new Map<string, InboxItem>();
// Sort by created_at DESC first, then dedup keeps first occurrence
const sorted = [...items].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
for (const item of sorted) {
if (!item.archived && !map.has(item.issue_id)) {
map.set(item.issue_id, item);
}
}
return Array.from(map.values());
}
export function inboxListOptions(workspaceId: string | null) {
return queryOptions({
queryKey: inboxKeys.list(workspaceId),
queryFn: () => api.listInbox(),
select: deduplicateInboxItems,
enabled: !!workspaceId,
});
}
/**
* Raw inbox items (not deduplicated). Used when you need the full list
* for batch operations or status updates.
*/
export function inboxRawListOptions(workspaceId: string | null) {
return queryOptions({
queryKey: inboxKeys.list(workspaceId),
queryFn: () => api.listInbox(),
enabled: !!workspaceId,
});
}
Key: deduplicateInboxItems is a select transform, so dedup happens on read, not on storage. The raw cache always holds the full list.
Step 2: Run typecheck
pnpm typecheck
Step 3: Commit
git add apps/web/core/inbox/queries.ts
git commit -m "feat(core/inbox): add query key factory with dedup select transform"
Task 2.2: Create inbox mutations
Files:
- Create:
apps/web/core/inbox/mutations.ts
Step 1: Write mutation hooks
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import { inboxKeys } from "./queries";
import type { InboxItem } from "@/shared/types";
export function useMarkInboxRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.markInboxRead(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.all });
const prev = qc.getQueriesData<InboxItem[]>({ queryKey: inboxKeys.all });
qc.setQueriesData<InboxItem[]>({ queryKey: inboxKeys.all }, (old) =>
old?.map((item) => (item.id === id ? { ...item, read: true } : item)),
);
return { prev };
},
onError: (_err, _id, ctx) => {
ctx?.prev?.forEach(([key, data]) => { if (data) qc.setQueryData(key, data); });
},
});
}
export function useArchiveInbox() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.archiveInbox(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.all });
const prev = qc.getQueriesData<InboxItem[]>({ queryKey: inboxKeys.all });
qc.setQueriesData<InboxItem[]>({ queryKey: inboxKeys.all }, (old) =>
old?.map((item) => (item.id === id ? { ...item, archived: true } : item)),
);
return { prev };
},
onError: (_err, _id, ctx) => {
ctx?.prev?.forEach(([key, data]) => { if (data) qc.setQueryData(key, data); });
},
});
}
export function useMarkAllInboxRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.markAllInboxRead(),
onMutate: async () => {
await qc.cancelQueries({ queryKey: inboxKeys.all });
const prev = qc.getQueriesData<InboxItem[]>({ queryKey: inboxKeys.all });
qc.setQueriesData<InboxItem[]>({ queryKey: inboxKeys.all }, (old) =>
old?.map((item) => (item.archived ? item : { ...item, read: true })),
);
return { prev };
},
onError: (_err, _vars, ctx) => {
ctx?.prev?.forEach(([key, data]) => { if (data) qc.setQueryData(key, data); });
},
});
}
export function useArchiveAllInbox() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.archiveAllInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all });
},
});
}
export function useArchiveAllReadInbox() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.archiveAllReadInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all });
},
});
}
Step 2: Run typecheck
pnpm typecheck
Step 3: Commit
git add apps/web/core/inbox/mutations.ts
git commit -m "feat(core/inbox): add inbox mutation hooks"
Task 2.3: Create inbox barrel + migrate consumers + update WS sync + delete store
Files:
- Create:
apps/web/core/inbox/index.ts - Modify:
apps/web/app/(dashboard)/inbox/page.tsx— replaceuseInboxStorereads +api.*calls - Modify:
apps/web/features/realtime/use-realtime-sync.ts— replace inbox store writes - Modify:
apps/web/features/workspace/store.ts— removeuseInboxStore.getState().fetch() - Modify:
apps/web/features/inbox/store.ts— delete or gut
Step 1: Create barrel
export { inboxKeys, inboxListOptions, inboxRawListOptions } from "./queries";
export {
useMarkInboxRead,
useArchiveInbox,
useMarkAllInboxRead,
useArchiveAllInbox,
useArchiveAllReadInbox,
} from "./mutations";
Step 2: Migrate inbox page
Replace all useInboxStore reads with useQuery(inboxListOptions(workspaceId)).
Replace all api.markInboxRead(), api.archiveInbox() etc. with mutation hooks.
Step 3: Update WS sync
// Before:
useInboxStore.getState().addItem(item);
// After:
queryClient.setQueryData<InboxItem[]>(
inboxKeys.list(workspaceId),
(old) => old && !old.some((i) => i.id === item.id) ? [item, ...old] : old,
);
Step 4: Remove inbox fetch from workspace store
In workspace/store.ts remove: useInboxStore.getState().fetch().catch(() => {}) from hydrateWorkspace.
Step 5: Delete inbox store server state
Delete apps/web/features/inbox/store.ts if nothing else uses it.
Step 6: Run full check
make check
Step 7: Commit
git commit -m "refactor(inbox): migrate to TanStack Query, delete useInboxStore"
Phase 3: Workspace Migration
Task 3.1: Create workspace query key factory and queryOptions
Files:
- Create:
apps/web/core/workspace/queries.ts
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
export const workspaceKeys = {
all: ["workspaces"] as const,
list: () => [...workspaceKeys.all, "list"] as const,
detail: (id: string) => [...workspaceKeys.all, "detail", id] as const,
members: (workspaceId: string) =>
[...workspaceKeys.all, "members", workspaceId] as const,
agents: (workspaceId: string) =>
[...workspaceKeys.all, "agents", workspaceId] as const,
skills: (workspaceId: string | null) =>
[...workspaceKeys.all, "skills", workspaceId] as const,
};
export function workspaceListOptions() {
return queryOptions({
queryKey: workspaceKeys.list(),
queryFn: () => api.listWorkspaces(),
});
}
export function memberListOptions(workspaceId: string | null) {
return queryOptions({
queryKey: workspaceKeys.members(workspaceId!),
queryFn: () => api.listMembers(workspaceId!),
enabled: !!workspaceId,
});
}
export function agentListOptions(workspaceId: string | null) {
return queryOptions({
queryKey: workspaceKeys.agents(workspaceId!),
queryFn: () =>
api.listAgents({ workspace_id: workspaceId!, include_archived: true }),
enabled: !!workspaceId,
});
}
export function skillListOptions(workspaceId: string | null) {
return queryOptions({
queryKey: workspaceKeys.skills(workspaceId),
queryFn: () => api.listSkills(),
enabled: !!workspaceId,
});
}
Task 3.2: Create workspace mutations
Files:
- Create:
apps/web/core/workspace/mutations.ts
Includes: useCreateWorkspace, useUpdateWorkspace, useLeaveWorkspace, useDeleteWorkspace, member CRUD mutations, agent CRUD mutations, skill CRUD mutations.
Task 3.3: Create workspace client store
Files:
- Create:
apps/web/core/workspace/store.ts
import { create } from "zustand";
import { api } from "@/shared/api";
interface WorkspaceClientState {
currentWorkspaceId: string | null;
setCurrentWorkspaceId: (id: string | null) => void;
}
export const useWorkspaceClientStore = create<WorkspaceClientState>((set) => ({
currentWorkspaceId: localStorage.getItem("multica_workspace_id"),
setCurrentWorkspaceId: (id) => {
if (id) {
localStorage.setItem("multica_workspace_id", id);
api.setWorkspaceId(id);
} else {
localStorage.removeItem("multica_workspace_id");
api.setWorkspaceId(null);
}
set({ currentWorkspaceId: id });
},
}));
This replaces the workspace selection logic from the old useWorkspaceStore. The actual workspace data (name, settings, etc.) comes from useQuery(workspaceListOptions()).
Task 3.4: Migrate workspace consumers + simplify AuthInitializer
Key change: Delete hydrateWorkspace(). The old orchestration:
hydrateWorkspace() → api.listMembers + api.listAgents + api.listSkills + issueStore.fetch + inboxStore.fetch
Becomes: Nothing. Each component calls useQuery(memberListOptions(workspaceId)) etc. When workspaceId changes, all queries with that key automatically refetch.
AuthInitializer simplification:
// Before: api.getMe() → api.listWorkspaces() → hydrateWorkspace(wsList, preferredId)
// After: api.getMe() → set user → set currentWorkspaceId (TQ handles the rest)
Task 3.5: Update workspace switch logic
Before: Manual cross-store clearing + rehydration
After: Just change currentWorkspaceId in the client store. TanStack Query sees new key → refetch. Optionally removeQueries for the old workspace to free memory.
function switchWorkspace(newId: string) {
const oldId = useWorkspaceClientStore.getState().currentWorkspaceId;
useWorkspaceClientStore.getState().setCurrentWorkspaceId(newId);
// Remove old workspace cache to free memory
if (oldId) {
queryClient.removeQueries({ queryKey: issueKeys.list(oldId) });
queryClient.removeQueries({ queryKey: inboxKeys.list(oldId) });
queryClient.removeQueries({ queryKey: workspaceKeys.members(oldId) });
queryClient.removeQueries({ queryKey: workspaceKeys.agents(oldId) });
}
}
Task 3.6: Update WS sync for workspace/member/agent/skill events
Replace all useWorkspaceStore.getState().refreshMembers() etc. with queryClient.invalidateQueries().
Task 3.7: Delete old useWorkspaceStore server state
Strip members[], agents[], skills[], workspace, workspaces[], and all refresh/hydrate methods. Or delete entirely if fully migrated.
Task 3.8: Update useWorkspaceId hook
// Before (bridging):
import { useWorkspaceStore } from "@/features/workspace";
export function useWorkspaceId() {
return useWorkspaceStore((s) => s.workspace?.id ?? null);
}
// After:
import { useWorkspaceClientStore } from "@core/workspace/store";
export function useWorkspaceId() {
return useWorkspaceClientStore((s) => s.currentWorkspaceId);
}
Task 3.9: Update useActorName hook
// Before: reads members/agents from useWorkspaceStore
// After: reads from TanStack Query cache
import { useQuery } from "@tanstack/react-query";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
export function useActorName() {
const workspaceId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(workspaceId));
const { data: agents = [] } = useQuery(agentListOptions(workspaceId));
// ... same logic, different data source
}
Run full check after Phase 3:
make check
Phase 4: Runtimes Migration
Task 4.1: Create runtime queries + store + migrate
Files:
- Create:
apps/web/core/runtimes/queries.ts - Create:
apps/web/core/runtimes/store.ts—{ selectedRuntimeId }only - Modify:
apps/web/features/runtimes/store.ts— delete server state - Modify:
apps/web/features/runtimes/components/runtimes-page.tsx - Modify:
apps/web/features/realtime/use-realtime-sync.ts— runtime events
// core/runtimes/queries.ts
export const runtimeKeys = {
all: ["runtimes"] as const,
list: (workspaceId: string | null) =>
[...runtimeKeys.all, "list", workspaceId] as const,
usage: (runtimeId: string, days?: number) =>
[...runtimeKeys.all, "usage", runtimeId, days] as const,
activity: (runtimeId: string) =>
[...runtimeKeys.all, "activity", runtimeId] as const,
};
Task 4.2: Create task queries
Files:
- Create:
apps/web/core/tasks/queries.ts
For agent-live-card.tsx which fetches getActiveTaskForIssue, listTaskMessages, listTasksByIssue.
Phase 5: Remaining Extraction + Cleanup
Task 5.1: Move shared/api/ → core/api/
Files:
- Move:
apps/web/shared/api/client.ts→apps/web/core/api/client.ts - Move:
apps/web/shared/api/ws-client.ts→apps/web/core/api/ws-client.ts - Move:
apps/web/shared/api/index.ts→apps/web/core/api/index.ts
Step 1: Move files
Step 2: Update ALL imports
grep -rn "from.*@/shared/api" apps/web/
Replace @/shared/api with @core/api everywhere.
Step 3: Run typecheck
pnpm typecheck
Step 4: Commit
git commit -m "refactor(core): move shared/api/ to core/api/"
Task 5.2: Move shared/types/ → core/types/
Files:
- Move entire
apps/web/shared/types/→apps/web/core/types/
Step 1: Move files
Step 2: Update ALL imports
grep -rn "from.*@/shared/types" apps/web/
Replace @/shared/types with @core/types everywhere.
Step 3: Run typecheck + commit
Task 5.3: Move shared/logger.ts → core/logger.ts
Task 5.4: Move auth store to core
Files:
- Move:
apps/web/features/auth/store.ts→apps/web/core/auth/store.ts - Modify:
apps/web/features/auth/initializer.tsx— update import - Modify: all
useAuthStoreconsumers
The auth store holds user (server state) and isLoading. In a full migration, user would become a TanStack Query query. But since auth is a singleton (not workspace-scoped) and rarely changes, it can stay as Zustand for now. The move to core/ is purely organizational.
Task 5.5: Extract WS sync to core/realtime/sync.ts
Move the logic from features/realtime/use-realtime-sync.ts to core/realtime/sync.ts.
The WSProvider component (contains JSX for React Context) stays in features/realtime/provider.tsx. The sync logic (pure hook, no JSX) moves to core.
Task 5.6: Move data hooks to core
After Tasks 1.5-1.7, the rewritten hooks (useIssueTimeline, useIssueReactions, useIssueSubscribers) are pure TanStack Query hooks with no JSX. Move them:
features/issues/hooks/use-issue-timeline.ts→core/issues/hooks/use-issue-timeline.tsfeatures/issues/hooks/use-issue-reactions.ts→core/issues/hooks/use-issue-reactions.tsfeatures/issues/hooks/use-issue-subscribers.ts→core/issues/hooks/use-issue-subscribers.ts
Task 5.7: Move shared/hooks/use-file-upload.ts to core
Task 5.8: Delete shared/ directory
Verify nothing imports from @/shared/:
grep -rn "from.*@/shared" apps/web/
If clean, delete apps/web/shared/.
Task 5.9: Create settings queries + mutations
Files:
- Create:
apps/web/core/settings/queries.ts—tokenQueriesfor PAT list - Create:
apps/web/core/settings/mutations.ts—useUpdateMe,useCreatePAT,useRevokePAT, member CRUD if not already in workspace mutations
Migrate:
apps/web/app/(dashboard)/settings/_components/account-tab.tsxapps/web/app/(dashboard)/settings/_components/members-tab.tsxapps/web/app/(dashboard)/settings/_components/workspace-tab.tsxapps/web/app/(dashboard)/settings/_components/repositories-tab.tsxapps/web/app/(dashboard)/settings/_components/tokens-tab.tsx
Task 5.10: Final verification
Run full check:
make check
Verify success criteria:
# Zero Zustand stores with server data
grep -rn "api\." apps/web/features/*/store.ts apps/web/features/*/stores/*.ts
# Should return nothing (no API calls in stores)
# All server data via TQ hooks
grep -rn "useQuery\|useMutation" apps/web/core/
# Should return many hits
# No direct api.* in features/ (except editor file upload which is OK)
grep -rn "api\.\(get\|list\|create\|update\|delete\|batch\|mark\|archive\)" apps/web/features/ apps/web/app/
# Should return nothing or only edge cases
# core/ has zero react-dom imports
grep -rn "react-dom" apps/web/core/
# Should return nothing
# core/ has zero JSX
grep -rn "tsx" apps/web/core/ --include="*.tsx"
# Should return nothing (all files should be .ts, not .tsx)
# shared/ directory deleted
ls apps/web/shared/ 2>&1
# Should return "No such file or directory"
Appendix A: Query Key Hierarchy
["issues"]
["issues", "list", workspaceId]
["issues", "detail", issueId]
["issues", "timeline", issueId]
["issues", "reactions", issueId]
["issues", "subscribers", issueId]
["inbox"]
["inbox", "list", workspaceId]
["inbox", "unread-count", workspaceId]
["workspaces"]
["workspaces", "list"]
["workspaces", "detail", workspaceId]
["workspaces", "members", workspaceId]
["workspaces", "agents", workspaceId]
["workspaces", "skills", workspaceId]
["runtimes"]
["runtimes", "list", workspaceId]
["runtimes", "usage", runtimeId, days]
["runtimes", "activity", runtimeId]
["tasks"]
["tasks", "active", issueId]
["tasks", "messages", taskId]
["tasks", "runs", issueId]
["tokens"]
["tokens", "list"]
Appendix B: WS Event → TanStack Query Mapping
| WS Event | TQ Operation | Key |
|---|---|---|
issue:created |
setQueryData (append) |
issueKeys.list(wsId) |
issue:updated |
setQueryData (patch) |
issueKeys.list(wsId) + issueKeys.detail(id) |
issue:deleted |
setQueryData (filter) + removeQueries |
issueKeys.list(wsId) + issueKeys.detail(id) |
comment:created |
setQueryData (append) |
issueKeys.timeline(issueId) |
comment:updated |
setQueryData (patch) |
issueKeys.timeline(issueId) |
comment:deleted |
setQueryData (filter) |
issueKeys.timeline(issueId) |
activity:created |
setQueryData (append) |
issueKeys.timeline(issueId) |
reaction:added/removed |
setQueryData (patch) |
issueKeys.timeline(issueId) |
issue_reaction:added/removed |
setQueryData (patch) |
issueKeys.reactions(issueId) |
subscriber:added/removed |
setQueryData (patch) |
issueKeys.subscribers(issueId) |
inbox:new |
setQueryData (prepend) |
inboxKeys.list(wsId) |
inbox:read/archived/batch-* |
invalidateQueries |
inboxKeys.all |
member:added/updated/removed |
invalidateQueries |
workspaceKeys.members(wsId) |
agent:* |
invalidateQueries |
workspaceKeys.agents(wsId) |
skill:* |
invalidateQueries |
workspaceKeys.skills(wsId) |
workspace:updated |
invalidateQueries |
workspaceKeys.list() |
workspace:deleted |
invalidateQueries + side effect |
workspaceKeys.list() |
daemon:register |
invalidateQueries |
runtimeKeys.list(wsId) |
| Reconnect | invalidateQueries |
All keys |
Appendix C: Future Phase 6-7 Reference
This plan intentionally structures core/ to be easily extractable to packages/core/ in Phase 6:
core/has zeroreact-domdependency — Desktop (Electron renderer) can import it directly.core/has zero Next.js dependency — Nonext/navigation,next/link, etc.core/exports only.tsfiles — No JSX, no components.- Import alias
@core/*— In Phase 6, change tsconfig to point@multica/core/*topackages/core/, or use package.json workspace imports.
The Phase 6 extraction is essentially:
mv apps/web/core/ packages/core/
# Update tsconfig aliases and package.json
Similarly, components/ui/ + components/common/ + hooks/ + lib/ will become packages/ui/ in Phase 6.