multica/docs/plans/2026-04-07-tanstack-query-migration.md
Naiyuan Qing 5fe1ec806d docs: add TanStack Query migration plan
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>
2026-04-07 14:20:43 +08:00

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. issueListOptions unwraps { 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 rollbackonMutate saves snapshot, patches cache; onError restores snapshot.
  • setQueriesData (plural) — Updates all matching caches (e.g. if two components have different list queries).
  • onSettled invalidation — 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 + cache
  • useMutation for createComment, editComment, deleteComment, toggleReaction
  • useWSEvent handlers call queryClient.setQueryData to append/update entries
  • useWSReconnect calls queryClient.invalidateQueries (replaces manual refetch)
  • Optimistic updates for comment CRUD and reactions

Key changes:

  • Remove all useState for timeline data
  • Remove all useEffect for 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))
  • useMutation for toggleReaction (optimistic add/remove in cache)
  • WS events issue_reaction:added / issue_reaction:removedqueryClient.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))
  • useMutation for toggleSubscriber (optimistic add/remove)
  • WS events subscriber:added / subscriber:removedqueryClient.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.tsx
  • apps/web/features/issues/components/board-view.tsx
  • apps/web/features/issues/components/list-view.tsx
  • apps/web/features/issues/components/board-card.tsx
  • apps/web/features/issues/components/batch-action-toolbar.tsx
  • apps/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.tsapps/web/core/issues/config/status.ts
  • Move: apps/web/features/issues/config/priority.tsapps/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 — replace useInboxStore reads + api.* calls
  • Modify: apps/web/features/realtime/use-realtime-sync.ts — replace inbox store writes
  • Modify: apps/web/features/workspace/store.ts — remove useInboxStore.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.tsapps/web/core/api/client.ts
  • Move: apps/web/shared/api/ws-client.tsapps/web/core/api/ws-client.ts
  • Move: apps/web/shared/api/index.tsapps/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.tsapps/web/core/auth/store.ts
  • Modify: apps/web/features/auth/initializer.tsx — update import
  • Modify: all useAuthStore consumers

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.tscore/issues/hooks/use-issue-timeline.ts
  • features/issues/hooks/use-issue-reactions.tscore/issues/hooks/use-issue-reactions.ts
  • features/issues/hooks/use-issue-subscribers.tscore/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.tstokenQueries for PAT list
  • Create: apps/web/core/settings/mutations.tsuseUpdateMe, useCreatePAT, useRevokePAT, member CRUD if not already in workspace mutations

Migrate:

  • apps/web/app/(dashboard)/settings/_components/account-tab.tsx
  • apps/web/app/(dashboard)/settings/_components/members-tab.tsx
  • apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx
  • apps/web/app/(dashboard)/settings/_components/repositories-tab.tsx
  • apps/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:

  1. core/ has zero react-dom dependency — Desktop (Electron renderer) can import it directly.
  2. core/ has zero Next.js dependency — No next/navigation, next/link, etc.
  3. core/ exports only .ts files — No JSX, no components.
  4. Import alias @core/* — In Phase 6, change tsconfig to point @multica/core/* to packages/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.