From 5fe1ec806ddfda99ad469f6a71f20040ba17fe8a Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:20:43 +0800 Subject: [PATCH] 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) --- .../2026-04-07-tanstack-query-migration.md | 1772 +++++++++++++++++ 1 file changed, 1772 insertions(+) create mode 100644 docs/plans/2026-04-07-tanstack-query-migration.md diff --git a/docs/plans/2026-04-07-tanstack-query-migration.md b/docs/plans/2026-04-07-tanstack-query-migration.md new file mode 100644 index 00000000..06f31c36 --- /dev/null +++ b/docs/plans/2026-04-07-tanstack-query-migration.md @@ -0,0 +1,1772 @@ +# 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.