# 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** ```bash cd apps/web && pnpm add @tanstack/react-query @tanstack/react-query-devtools ``` **Step 2: Verify installation** ```bash pnpm typecheck ``` Expected: PASS (no type errors from new deps) **Step 3: Commit** ```bash 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** ```typescript 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** ```bash pnpm typecheck ``` **Step 3: Commit** ```bash 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`: ```json { "paths": { "@/*": ["./*"], "@core/*": ["./core/*"] } } ``` **Step 2: Run typecheck** ```bash pnpm typecheck ``` **Step 3: Commit** ```bash 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** ```typescript "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 ( {children} ); } ``` **Step 2: Wrap root layout** In `apps/web/app/layout.tsx`, add `QueryProvider` inside `ThemeProvider`, wrapping everything: ```tsx import { QueryProvider } from "@core/provider"; // In the JSX: {children} ``` **Step 3: Run typecheck and dev server** ```bash pnpm typecheck pnpm dev:web # Verify app loads, check devtools panel appears ``` **Step 4: Commit** ```bash 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. ```typescript 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** ```bash pnpm typecheck ``` **Step 3: Commit** ```bash 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** ```typescript 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** ```bash pnpm typecheck ``` **Step 3: Commit** ```bash 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** ```typescript 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( 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({ queryKey: issueKeys.lists(), }); // Optimistic update: patch issue in all list caches qc.setQueriesData({ queryKey: issueKeys.lists() }, (old) => old?.map((i) => (i.id === id ? { ...i, ...data } : i)), ); // Also update detail cache if it exists qc.setQueryData(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({ queryKey: issueKeys.lists(), }); qc.setQueriesData({ 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({ queryKey: issueKeys.lists(), }); qc.setQueriesData({ 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({ queryKey: issueKeys.lists(), }); qc.setQueriesData({ 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** — `onMutate` 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** ```bash 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** ```bash 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** ```typescript import { create } from "zustand"; interface IssueClientState { activeIssueId: string | null; setActiveIssue: (id: string | null) => void; } export const useIssueClientStore = create((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** ```bash 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** ```typescript export { issueKeys, issueListOptions, issueDetailOptions, issueTimelineOptions, issueReactionsOptions, issueSubscribersOptions, } from "./queries"; export { useCreateIssue, useUpdateIssue, useDeleteIssue, useBatchUpdateIssues, useBatchDeleteIssues, } from "./mutations"; export { useIssueClientStore } from "./store"; ``` **Step 2: Commit** ```bash 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` + 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** ```bash pnpm typecheck pnpm test ``` **Step 4: Commit** ```bash 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:removed` → `queryClient.setQueryData` **Step 2: Run typecheck** ```bash pnpm typecheck ``` **Step 3: Commit** ```bash 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:removed` → `queryClient.setQueryData` **Step 2: Run typecheck** ```bash pnpm typecheck ``` **Step 3: Commit** ```bash 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: ```typescript // 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.: ```typescript // 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** ```typescript // 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** ```bash pnpm typecheck pnpm test ``` **Step 5: Commit per file or group** ```bash 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** ```typescript // Before: useState + useEffect + api.getIssue(id) // After: const { data: issue, isLoading } = useQuery(issueDetailOptions(issueId)); ``` **Step 2: Replace mutation calls** ```typescript // 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** ```bash pnpm typecheck ``` **Step 4: Commit** ```bash 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** ```typescript // Before: const issue = await api.createIssue(data); // After: const createIssue = useCreateIssue(); const issue = await createIssue.mutateAsync(data); ``` **Step 2: Run typecheck** ```bash pnpm typecheck ``` **Step 3: Commit** ```bash 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** ```typescript // 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( issueKeys.list(workspaceId), (old) => old?.map((i) => (i.id === issue.id ? { ...i, ...issue } : i)), ); // Also update detail cache queryClient.setQueryData( issueKeys.detail(issue.id), (old) => old ? { ...old, ...issue } : old, ); // issue:created → append to list cache queryClient.setQueryData( issueKeys.list(workspaceId), (old) => old && !old.some((i) => i.id === issue.id) ? [...old, issue] : old, ); // issue:deleted → remove from list cache queryClient.setQueryData( issueKeys.list(workspaceId), (old) => old?.filter((i) => i.id !== issue_id), ); queryClient.removeQueries({ queryKey: issueKeys.detail(issue_id) }); ``` **Step 2: Replace reconnect handler** ```typescript // 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** ```bash pnpm typecheck ``` **Step 4: Commit** ```bash 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`. ```bash # 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: ```typescript 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** ```bash make check ``` **Step 5: Commit** ```bash 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** ```bash grep -rn "from.*issues/config" apps/web/ ``` Replace `@/features/issues/config` with `@core/issues/config` in all consumers. **Step 3: Run typecheck** ```bash pnpm typecheck ``` **Step 4: Commit** ```bash 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** ```typescript 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(); // 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** ```bash pnpm typecheck ``` **Step 3: Commit** ```bash 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** ```typescript 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({ queryKey: inboxKeys.all }); qc.setQueriesData({ 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({ queryKey: inboxKeys.all }); qc.setQueriesData({ 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({ queryKey: inboxKeys.all }); qc.setQueriesData({ 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** ```bash pnpm typecheck ``` **Step 3: Commit** ```bash 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** ```typescript 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** ```typescript // Before: useInboxStore.getState().addItem(item); // After: queryClient.setQueryData( 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** ```bash make check ``` **Step 7: Commit** ```bash 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` ```typescript 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` ```typescript import { create } from "zustand"; import { api } from "@/shared/api"; interface WorkspaceClientState { currentWorkspaceId: string | null; setCurrentWorkspaceId: (id: string | null) => void; } export const useWorkspaceClientStore = create((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:** ```typescript // 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. ```typescript 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 ```typescript // 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 ```typescript // 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:** ```bash 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 ```typescript // 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** ```bash grep -rn "from.*@/shared/api" apps/web/ ``` Replace `@/shared/api` with `@core/api` everywhere. **Step 3: Run typecheck** ```bash pnpm typecheck ``` **Step 4: Commit** ```bash 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** ```bash 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 `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.ts` → `core/issues/hooks/use-issue-timeline.ts` - `features/issues/hooks/use-issue-reactions.ts` → `core/issues/hooks/use-issue-reactions.ts` - `features/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/`: ```bash 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` — `tokenQueries` for 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.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:** ```bash make check ``` **Verify success criteria:** ```bash # 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: ```bash 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.