diff --git a/CLAUDE.md b/CLAUDE.md index 355eed49..6fffc71b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,65 +24,94 @@ The frontend uses a **feature-based architecture** with four layers: ``` apps/web/ ├── app/ # Routing layer (thin shells — import from features/) -├── features/ # Business logic, organized by domain +├── core/ # Headless business logic (TanStack Query, zero JSX, zero react-dom) +├── features/ # UI business components, organized by domain ├── shared/ # Cross-feature utilities (api client, types, logger) ``` **`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`. -**`features/`** — Domain modules, each with its own components, hooks, stores, and config: +**`core/`** — Headless business logic. Query key factories, `queryOptions`, mutation hooks, WS cache updaters. **No JSX, no react-dom.** Designed for future extraction to `packages/core/` in a monorepo. + +| Module | Purpose | Key exports | +|---|---|---| +| `core/issues/` | Issue queries, mutations, WS updaters | `issueListOptions`, `useUpdateIssue`, `onIssueUpdated` | +| `core/inbox/` | Inbox queries, mutations, WS updaters | `inboxListOptions`, `useMarkInboxRead` | +| `core/workspace/` | Member/agent/skill queries, workspace mutations | `memberListOptions`, `agentListOptions` | +| `core/runtimes/` | Runtime queries | `runtimeListOptions` | +| `core/query-client.ts` | QueryClient factory | `createQueryClient` | +| `core/provider.tsx` | QueryClientProvider wrapper | `QueryProvider` | +| `core/hooks.ts` | Shared hooks | `useWorkspaceId` | + +**`features/`** — Domain modules with UI components, client-only stores, and config: | Feature | Purpose | Exports | |---|---|---| | `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` | -| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` | -| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config | -| `features/inbox/` | Inbox notification state | `useInboxStore` | +| `features/workspace/` | Workspace identity + UI | `useWorkspaceStore` (client-only: workspace/workspaces), `useActorName` | +| `features/issues/` | Issue UI components + client state | `useIssueStore` (client-only: activeIssueId), icons, pickers, config | | `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` | | `features/modals/` | Modal registry and state | Modal store and components | | `features/skills/` | Skill management | Skill components | -**`shared/`** — Code used across multiple features: +**`shared/`** — Code used across multiple features (will migrate to `core/` in Phase 5): - `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton. - `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types. - `shared/logger.ts` — Logger utility. ### State Management -- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`). +- **TanStack Query** for all server state — issues, inbox, members, agents, skills, runtimes. Query definitions live in `core//queries.ts`, mutations in `core//mutations.ts`. +- **Zustand** for client-only state — UI selections (`activeIssueId`), view filters, modal state, workspace identity, navigation. No API calls in Zustand stores. - **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`). - **Local `useState`** for component-scoped UI state (forms, modals, filters). -- Do not use React Context for data that can be a zustand store. -**Store conventions:** -- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`. +**TanStack Query conventions:** +- `staleTime: Infinity` — WS events handle cache freshness, no polling or refetch-on-focus. +- WS events trigger `queryClient.invalidateQueries()` (preferred) or `queryClient.setQueryData()` for granular updates. +- All workspace-scoped query keys include `wsId` — workspace switch automatically uses new cache. +- Mutations use `onMutate` for optimistic updates + `onError` for rollback + `onSettled` for invalidation. +- Components access QueryClient via `useQueryClient()` hook. Non-React contexts (e.g. Tiptap plugin callbacks) receive QueryClient via closure from the parent React component — never use module-level singletons. + +**Zustand store conventions:** +- Stores hold only client state (UI selections, persisted preferences). Zero `api.*` calls in stores. +- Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`. - Stores must not call `useRouter` or any React hooks — keep navigation in components. -- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks). -- Dependency direction: `workspace` → `auth`, `realtime` → `auth`, `issues` → `workspace`. Never reverse. +- `useWorkspaceStore` manages workspace identity (`workspace`, `workspaces`, `api.setWorkspaceId`, localStorage). Server data (members, agents, skills) is in TanStack Query, not the store. ### Import Aliases -Use `@/` alias (maps to `apps/web/`): +Use `@/` alias (maps to `apps/web/`) and `@core/` alias (maps to `apps/web/core/`): ```typescript +// Core (headless business logic) +import { issueListOptions, issueKeys } from "@core/issues/queries"; +import { useUpdateIssue, useCreateIssue } from "@core/issues/mutations"; +import { memberListOptions, agentListOptions } from "@core/workspace/queries"; +import { useWorkspaceId } from "@core/hooks"; + +// Shared (api client, types) import { api } from "@/shared/api"; import type { Issue } from "@/shared/types"; + +// Features (UI components, client stores) import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; -import { useIssueStore } from "@/features/issues"; -import { useInboxStore } from "@/features/inbox"; import { useWSEvent } from "@/features/realtime"; import { StatusIcon } from "@/features/issues/components"; ``` -Within a feature, use relative imports. Between features or to shared, use `@/`. +Within a feature, use relative imports. Between features or to shared, use `@/`. For core modules, use `@core/`. ### Data Flow ``` -Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL -Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService +Browser → useQuery (core/) → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL +Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast() ``` +Mutations: `useMutation (core/)` → optimistic cache update → API call → onSettled invalidation. +WS events: `use-realtime-sync.ts` → `queryClient.invalidateQueries()` for most events, `setQueryData()` for granular issue/inbox updates. + ### Backend Structure (`server/`) - **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate` @@ -177,7 +206,7 @@ make start-worktree # Start using .env.worktree - Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`. - **Feature-specific components** → `features//components/` — issue icons, pickers, and other domain-bound UI live inside their feature module. - Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`). -- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context. +- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Server data goes through TanStack Query (`core/`), client-only shared state through Zustand, React Context only for connection lifecycle. - Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency. - When unsure about interaction or state design, ask — the user will provide direction. diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index 257bb518..f3072c30 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import React from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { @@ -42,7 +43,9 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; -import { useInboxStore } from "@/features/inbox"; +import { useQuery } from "@tanstack/react-query"; +import { inboxKeys, deduplicateInboxItems } from "@core/inbox/queries"; +import { api } from "@/shared/api"; import { useModalStore } from "@/features/modals"; const primaryNav = [ @@ -73,7 +76,16 @@ export function AppSidebar() { const workspaces = useWorkspaceStore((s) => s.workspaces); const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace); - const unreadCount = useInboxStore((s) => s.unreadCount()); + const wsId = workspace?.id; + const { data: inboxItems = [] } = useQuery({ + queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"], + queryFn: () => api.listInbox(), + enabled: !!wsId, + }); + const unreadCount = React.useMemo( + () => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length, + [inboxItems], + ); const logout = () => { router.push("/"); diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 8ada0114..10b184ec 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -67,8 +67,11 @@ import { Skeleton } from "@/components/ui/skeleton"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; -import { useRuntimeStore } from "@/features/runtimes"; -import { useIssueStore } from "@/features/issues"; +import { runtimeListOptions } from "@core/runtimes/queries"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useWorkspaceId } from "@core/hooks"; +import { issueListOptions } from "@core/issues/queries"; +import { skillListOptions, agentListOptions, workspaceKeys } from "@core/workspace/queries"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { useFileUpload } from "@/shared/hooks/use-file-upload"; @@ -431,8 +434,9 @@ function SkillsTab({ }: { agent: Agent; }) { - const workspaceSkills = useWorkspaceStore((s) => s.skills); - const refreshAgents = useWorkspaceStore((s) => s.refreshAgents); + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId)); const [saving, setSaving] = useState(false); const [showPicker, setShowPicker] = useState(false); @@ -444,7 +448,7 @@ function SkillsTab({ try { const newIds = [...agent.skills.map((s) => s.id), skillId]; await api.setAgentSkills(agent.id, { skill_ids: newIds }); - await refreshAgents(); + qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to add skill"); } finally { @@ -458,7 +462,7 @@ function SkillsTab({ try { const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id); await api.setAgentSkills(agent.id, { skill_ids: newIds }); - await refreshAgents(); + qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to remove skill"); } finally { @@ -591,7 +595,8 @@ function SkillsTab({ function TasksTab({ agent }: { agent: Agent }) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); - const issues = useIssueStore((s) => s.issues); + const wsId = useWorkspaceId(); + const { data: issues = [] } = useQuery(issueListOptions(wsId)); useEffect(() => { setLoading(true); @@ -1065,21 +1070,17 @@ function AgentDetail({ export default function AgentsPage() { const isLoading = useAuthStore((s) => s.isLoading); const workspace = useWorkspaceStore((s) => s.workspace); - const agents = useWorkspaceStore((s) => s.agents); - const refreshAgents = useWorkspaceStore((s) => s.refreshAgents); + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); const [selectedId, setSelectedId] = useState(""); const [showArchived, setShowArchived] = useState(false); const [showCreate, setShowCreate] = useState(false); - const runtimes = useRuntimeStore((s) => s.runtimes); - const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes); + const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId)); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_agents_layout", }); - useEffect(() => { - if (workspace) fetchRuntimes(); - }, [workspace, fetchRuntimes]); - const filteredAgents = useMemo( () => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at), [agents, showArchived], @@ -1096,14 +1097,14 @@ export default function AgentsPage() { const handleCreate = async (data: CreateAgentRequest) => { const agent = await api.createAgent(data); - await refreshAgents(); + qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }); setSelectedId(agent.id); }; const handleUpdate = async (id: string, data: Record) => { try { await api.updateAgent(id, data as UpdateAgentRequest); - await refreshAgents(); + qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }); toast.success("Agent updated"); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to update agent"); @@ -1114,7 +1115,7 @@ export default function AgentsPage() { const handleArchive = async (id: string) => { try { await api.archiveAgent(id); - await refreshAgents(); + qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }); toast.success("Agent archived"); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to archive agent"); @@ -1124,7 +1125,7 @@ export default function AgentsPage() { const handleRestore = async (id: string) => { try { await api.restoreAgent(id); - await refreshAgents(); + qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }); toast.success("Agent restored"); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to restore agent"); diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index ff48d287..fc68488f 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -1,9 +1,22 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { useDefaultLayout } from "react-resizable-panels"; -import { useInboxStore } from "@/features/inbox"; +import { useQuery } from "@tanstack/react-query"; +import { useWorkspaceId } from "@core/hooks"; +import { + inboxListOptions, + deduplicateInboxItems, +} from "@core/inbox/queries"; +import { + useMarkInboxRead, + useArchiveInbox, + useMarkAllInboxRead, + useArchiveAllInbox, + useArchiveAllReadInbox, + useArchiveCompletedInbox, +} from "@core/inbox/mutations"; import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components"; import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config"; import { useActorName } from "@/features/workspace"; @@ -33,7 +46,6 @@ import { DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { api } from "@/shared/api"; // --------------------------------------------------------------------------- // Helpers @@ -235,8 +247,9 @@ export default function InboxPage() { window.history.replaceState(null, "", url); }, []); - const items = useInboxStore((s) => s.dedupedItems()); - const loading = useInboxStore((s) => s.loading); + const wsId = useWorkspaceId(); + const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId)); + const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_inbox_layout", @@ -245,74 +258,58 @@ export default function InboxPage() { const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null; const unreadCount = items.filter((i) => !i.read).length; + const markReadMutation = useMarkInboxRead(); + const archiveMutation = useArchiveInbox(); + const markAllReadMutation = useMarkAllInboxRead(); + const archiveAllMutation = useArchiveAllInbox(); + const archiveAllReadMutation = useArchiveAllReadInbox(); + const archiveCompletedMutation = useArchiveCompletedInbox(); + // Click-to-read: select + auto-mark-read - const handleSelect = async (item: InboxItem) => { + const handleSelect = (item: InboxItem) => { setSelectedKey(item.issue_id ?? item.id); if (!item.read) { - useInboxStore.getState().markRead(item.id); - try { - await api.markInboxRead(item.id); - } catch { - // Rollback: refetch to get server truth - useInboxStore.getState().fetch(); - toast.error("Failed to mark as read"); - } + markReadMutation.mutate(item.id, { + onError: () => toast.error("Failed to mark as read"), + }); } }; - const handleArchive = async (id: string) => { - try { - await api.archiveInbox(id); - useInboxStore.getState().archive(id); - const archived = items.find((i) => i.id === id); - if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey(""); - } catch { - toast.error("Failed to archive"); - } + const handleArchive = (id: string) => { + const archived = items.find((i) => i.id === id); + if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey(""); + archiveMutation.mutate(id, { + onError: () => toast.error("Failed to archive"), + }); }; // Batch operations - const handleMarkAllRead = async () => { - try { - useInboxStore.getState().markAllRead(); - await api.markAllInboxRead(); - } catch { - toast.error("Failed to mark all as read"); - useInboxStore.getState().fetch(); - } + const handleMarkAllRead = () => { + markAllReadMutation.mutate(undefined, { + onError: () => toast.error("Failed to mark all as read"), + }); }; - const handleArchiveAll = async () => { - try { - useInboxStore.getState().archiveAll(); - setSelectedKey(""); - await api.archiveAllInbox(); - } catch { - toast.error("Failed to archive all"); - useInboxStore.getState().fetch(); - } + const handleArchiveAll = () => { + setSelectedKey(""); + archiveAllMutation.mutate(undefined, { + onError: () => toast.error("Failed to archive all"), + }); }; - const handleArchiveAllRead = async () => { - try { - const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id); - useInboxStore.getState().archiveAllRead(); - if (readKeys.includes(selectedKey)) setSelectedKey(""); - await api.archiveAllReadInbox(); - } catch { - toast.error("Failed to archive read items"); - useInboxStore.getState().fetch(); - } + const handleArchiveAllRead = () => { + const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id); + if (readKeys.includes(selectedKey)) setSelectedKey(""); + archiveAllReadMutation.mutate(undefined, { + onError: () => toast.error("Failed to archive read items"), + }); }; - const handleArchiveCompleted = async () => { - try { - await api.archiveCompletedInbox(); - setSelectedKey(""); - await useInboxStore.getState().fetch(); - } catch { - toast.error("Failed to archive completed"); - } + const handleArchiveCompleted = () => { + setSelectedKey(""); + archiveCompletedMutation.mutate(undefined, { + onError: () => toast.error("Failed to archive completed"), + }); }; if (loading) { diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 7ec44b49..e4fa7515 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -2,6 +2,7 @@ import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "rea import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, Comment, TimelineEntry } from "@/shared/types"; // Mock next/navigation @@ -62,34 +63,11 @@ vi.mock("@/features/workspace", () => ({ }), })); -// Mock issue store — supply a stable full issue object so storeIssue -// doesn't create a new reference each render (avoids infinite effect loop) -// and has all required fields for rendering. -const stableStoreIssues = vi.hoisted(() => [ - { - id: "issue-1", - workspace_id: "ws-1", - number: 1, - identifier: "TES-1", - title: "Implement authentication", - description: "Add JWT auth to the backend", - status: "in_progress", - priority: "high", - assignee_type: "member", - assignee_id: "user-1", - creator_type: "member", - creator_id: "user-1", - parent_issue_id: null, - position: 0, - due_date: "2026-06-01T00:00:00Z", - created_at: "2026-01-15T00:00:00Z", - updated_at: "2026-01-20T00:00:00Z", - }, -]); +// Mock issue store — only client state remains (activeIssueId) vi.mock("@/features/issues", () => ({ useIssueStore: Object.assign( - (selector: (s: any) => any) => selector({ issues: stableStoreIssues }), - { getState: () => ({ issues: stableStoreIssues, addIssue: vi.fn(), updateIssue: vi.fn(), removeIssue: vi.fn() }) }, + (selector: (s: any) => any) => selector({ activeIssueId: null }), + { getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) }, ), })); @@ -235,14 +213,26 @@ const mockTimeline: TimelineEntry[] = [ import IssueDetailPage from "./page"; +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); +} + // React 19 use(Promise) needs the promise to resolve within act + Suspense async function renderPage(id = "issue-1") { + const queryClient = createTestQueryClient(); let result: ReturnType; await act(async () => { result = render( - Suspense loading...}> - - , + + Suspense loading...}> + + + , ); }); return result!; diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index c48dc4f8..86389051 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@/shared/types"; // Mock next/navigation @@ -61,36 +62,28 @@ vi.mock("sonner", () => ({ // Mock api const mockUpdateIssue = vi.fn(); +const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 })); vi.mock("@/shared/api", () => ({ api: { - listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }), + listIssues: (...args: any[]) => mockListIssues(...args), updateIssue: (...args: any[]) => mockUpdateIssue(...args), }, })); -// Mock the issue store -let mockStoreState: { - issues: Issue[]; - loading: boolean; - fetch: () => Promise; - setIssues: (issues: Issue[]) => void; - addIssue: (issue: Issue) => void; - updateIssue: (id: string, updates: Partial) => void; - removeIssue: (id: string) => void; -}; - +// Mock issue store — only client state remains +const mockIssueClientState = { activeIssueId: null, setActiveIssue: vi.fn() }; vi.mock("@/features/issues/store", () => ({ useIssueStore: Object.assign( - (selector?: any) => (selector ? selector(mockStoreState) : mockStoreState), - { getState: () => mockStoreState }, + (selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState), + { getState: () => mockIssueClientState }, ), })); vi.mock("@/features/issues", () => ({ useIssueStore: Object.assign( - (selector?: any) => (selector ? selector(mockStoreState) : mockStoreState), - { getState: () => mockStoreState }, + (selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState), + { getState: () => mockIssueClientState }, ), StatusIcon: () => null, PriorityIcon: () => null, @@ -282,90 +275,80 @@ const mockIssues: Issue[] = [ import IssuesPage from "./page"; +function renderWithQuery(ui: React.ReactElement) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } }); + return render({ui}); +} + describe("IssuesPage", () => { beforeEach(() => { vi.clearAllMocks(); - mockStoreState = { - issues: [], - loading: true, - fetch: vi.fn(), - setIssues: vi.fn(), - addIssue: vi.fn(), - updateIssue: vi.fn(), - removeIssue: vi.fn(), - }; + mockListIssues.mockResolvedValue({ issues: [], total: 0 }); mockViewState.viewMode = "board"; mockViewState.statusFilters = []; mockViewState.priorityFilters = []; }); it("shows loading state initially", () => { - mockStoreState.loading = true; - mockStoreState.issues = []; - render(); + renderWithQuery(); expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true); }); it("renders issues in board view after loading", async () => { - mockStoreState.loading = false; - mockStoreState.issues = mockIssues; + // issueListOptions makes 2 calls: open_only + closed page. Return issues for open, empty for closed. + mockListIssues.mockImplementation((params: any) => + Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }), + ); - render(); + renderWithQuery(); - expect(screen.getByText("Implement auth")).toBeInTheDocument(); + await screen.findByText("Implement auth"); expect(screen.getByText("Design landing page")).toBeInTheDocument(); expect(screen.getByText("Write tests")).toBeInTheDocument(); }); it("renders board columns", async () => { - mockStoreState.loading = false; - mockStoreState.issues = mockIssues; + mockListIssues.mockImplementation((params: any) => + Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }), + ); - render(); + renderWithQuery(); - expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1); + await screen.findByText("Backlog"); expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1); }); - it("shows workspace breadcrumb", () => { - mockStoreState.loading = false; - mockStoreState.issues = []; + it("shows workspace breadcrumb", async () => { + renderWithQuery(); - render(); - - expect(screen.getByText("Issues")).toBeInTheDocument(); + await screen.findByText("Issues"); }); - it("shows scope buttons", () => { - mockStoreState.loading = false; - mockStoreState.issues = []; + it("shows scope buttons", async () => { + renderWithQuery(); - render(); - - expect(screen.getByText("All")).toBeInTheDocument(); + await screen.findByText("All"); expect(screen.getByText("Members")).toBeInTheDocument(); expect(screen.getByText("Agents")).toBeInTheDocument(); }); - it("shows filter and display icon buttons", () => { - mockStoreState.loading = false; - mockStoreState.issues = mockIssues; + it("shows filter and display icon buttons", async () => { + mockListIssues.mockImplementation((params: any) => + Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }), + ); - render(); + renderWithQuery(); - // Filter and Display are now icon-only buttons, verify they render as buttons + await screen.findByText("Implement auth"); const buttons = screen.getAllByRole("button"); expect(buttons.length).toBeGreaterThan(0); }); it("shows empty board view when no issues exist", () => { - mockStoreState.loading = false; - mockStoreState.issues = []; - - render(); + renderWithQuery(); // Should still render the board/list view, not a "no issues" message expect(screen.queryByText("No matching issues")).not.toBeInTheDocument(); diff --git a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx index e49d5403..1385f051 100644 --- a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx @@ -36,8 +36,11 @@ import { DropdownMenuSubContent, } from "@/components/ui/dropdown-menu"; import { toast } from "sonner"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; +import { useWorkspaceId } from "@core/hooks"; +import { memberListOptions, workspaceKeys } from "@core/workspace/queries"; import { api } from "@/shared/api"; const roleConfig: Record = { @@ -140,8 +143,9 @@ function MemberRow({ export function MembersTab() { const user = useAuthStore((s) => s.user); const workspace = useWorkspaceStore((s) => s.workspace); - const members = useWorkspaceStore((s) => s.members); - const refreshMembers = useWorkspaceStore((s) => s.refreshMembers); + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("member"); @@ -168,7 +172,7 @@ export function MembersTab() { }); setInviteEmail(""); setInviteRole("member"); - await refreshMembers(); + qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) }); toast.success("Member added"); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to add member"); @@ -182,7 +186,7 @@ export function MembersTab() { setMemberActionId(memberId); try { await api.updateMember(workspace.id, memberId, { role }); - await refreshMembers(); + qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) }); toast.success("Role updated"); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to update member"); @@ -201,7 +205,7 @@ export function MembersTab() { setMemberActionId(member.id); try { await api.deleteMember(workspace.id, member.id); - await refreshMembers(); + qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) }); toast.success("Member removed"); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to remove member"); diff --git a/apps/web/app/(dashboard)/settings/_components/repositories-tab.tsx b/apps/web/app/(dashboard)/settings/_components/repositories-tab.tsx index 4b352bd3..3ccef144 100644 --- a/apps/web/app/(dashboard)/settings/_components/repositories-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/repositories-tab.tsx @@ -6,15 +6,19 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; +import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; +import { useWorkspaceId } from "@core/hooks"; +import { memberListOptions } from "@core/workspace/queries"; import { api } from "@/shared/api"; import type { WorkspaceRepo } from "@/shared/types"; export function RepositoriesTab() { const user = useAuthStore((s) => s.user); const workspace = useWorkspaceStore((s) => s.workspace); - const members = useWorkspaceStore((s) => s.members); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace); const [repos, setRepos] = useState(workspace?.repos ?? []); diff --git a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx index 594e8c45..17988cf2 100644 --- a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx @@ -18,14 +18,18 @@ import { AlertDialogAction, } from "@/components/ui/alert-dialog"; import { toast } from "sonner"; +import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; +import { useWorkspaceId } from "@core/hooks"; +import { memberListOptions } from "@core/workspace/queries"; import { api } from "@/shared/api"; export function WorkspaceTab() { const user = useAuthStore((s) => s.user); const workspace = useWorkspaceStore((s) => s.workspace); - const members = useWorkspaceStore((s) => s.members); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace); const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace); const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 0e4f8dd2..c1df5730 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; +import { QueryProvider } from "@core/provider"; import { AuthInitializer } from "@/features/auth"; import { WSProvider } from "@/features/realtime"; import { ModalRegistry } from "@/features/modals"; @@ -67,11 +68,13 @@ export default async function RootLayout({ > - - {children} - - - + + + {children} + + + + diff --git a/apps/web/components/common/mention-hover-card.tsx b/apps/web/components/common/mention-hover-card.tsx index a3986686..90f825d4 100644 --- a/apps/web/components/common/mention-hover-card.tsx +++ b/apps/web/components/common/mention-hover-card.tsx @@ -2,9 +2,11 @@ import type { ReactNode } from "react"; import { Users } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"; import { ActorAvatar } from "@/components/common/actor-avatar"; -import { useWorkspaceStore } from "@/features/workspace"; +import { useWorkspaceId } from "@core/hooks"; +import { memberListOptions, agentListOptions } from "@core/workspace/queries"; interface MentionHoverCardProps { type: string; @@ -13,8 +15,9 @@ interface MentionHoverCardProps { } function MentionHoverCard({ type, id, children }: MentionHoverCardProps) { - const members = useWorkspaceStore((s) => s.members); - const agents = useWorkspaceStore((s) => s.agents); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); if (type === "all") { return ( diff --git a/apps/web/core/hooks.ts b/apps/web/core/hooks.ts new file mode 100644 index 00000000..cdfbc033 --- /dev/null +++ b/apps/web/core/hooks.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useWorkspaceStore } from "@/features/workspace"; + +/** + * Returns the current workspace ID. + * + * Bridge hook: reads from Zustand workspace store now. + * Phase 3 will switch to core/workspace/store.ts — signature stays the same. + */ +export function useWorkspaceId(): string { + const workspaceId = useWorkspaceStore((s) => s.workspace?.id); + if (!workspaceId) { + throw new Error("useWorkspaceId: no workspace selected"); + } + return workspaceId; +} diff --git a/apps/web/core/inbox/index.ts b/apps/web/core/inbox/index.ts new file mode 100644 index 00000000..95a8ffa1 --- /dev/null +++ b/apps/web/core/inbox/index.ts @@ -0,0 +1,16 @@ +export { + inboxKeys, + inboxListOptions, + deduplicateInboxItems, +} from "./queries"; + +export { + useMarkInboxRead, + useArchiveInbox, + useMarkAllInboxRead, + useArchiveAllInbox, + useArchiveAllReadInbox, + useArchiveCompletedInbox, +} from "./mutations"; + +export { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "./ws-updaters"; diff --git a/apps/web/core/inbox/mutations.ts b/apps/web/core/inbox/mutations.ts new file mode 100644 index 00000000..9d67cc05 --- /dev/null +++ b/apps/web/core/inbox/mutations.ts @@ -0,0 +1,113 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/shared/api"; +import { inboxKeys } from "./queries"; +import { useWorkspaceId } from "@core/hooks"; +import type { InboxItem } from "@/shared/types"; + +export function useMarkInboxRead() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (id: string) => api.markInboxRead(id), + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) }); + const prev = qc.getQueryData(inboxKeys.list(wsId)); + qc.setQueryData(inboxKeys.list(wsId), (old) => + old?.map((item) => (item.id === id ? { ...item, read: true } : item)), + ); + return { prev }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); + }, + }); +} + +export function useArchiveInbox() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (id: string) => api.archiveInbox(id), + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) }); + const prev = qc.getQueryData(inboxKeys.list(wsId)); + // Archive all items for the same issue (same behavior as store) + const target = prev?.find((i) => i.id === id); + const issueId = target?.issue_id; + qc.setQueryData(inboxKeys.list(wsId), (old) => + old?.map((item) => + item.id === id || (issueId && item.issue_id === issueId) + ? { ...item, archived: true } + : item, + ), + ); + return { prev }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); + }, + }); +} + +export function useMarkAllInboxRead() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: () => api.markAllInboxRead(), + onMutate: async () => { + await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) }); + const prev = qc.getQueryData(inboxKeys.list(wsId)); + qc.setQueryData(inboxKeys.list(wsId), (old) => + old?.map((item) => + !item.archived ? { ...item, read: true } : item, + ), + ); + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); + }, + }); +} + +export function useArchiveAllInbox() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: () => api.archiveAllInbox(), + onSettled: () => { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); + }, + }); +} + +export function useArchiveAllReadInbox() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: () => api.archiveAllReadInbox(), + onSettled: () => { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); + }, + }); +} + +export function useArchiveCompletedInbox() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: () => api.archiveCompletedInbox(), + onSettled: () => { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); + }, + }); +} diff --git a/apps/web/core/inbox/queries.ts b/apps/web/core/inbox/queries.ts new file mode 100644 index 00000000..d705b130 --- /dev/null +++ b/apps/web/core/inbox/queries.ts @@ -0,0 +1,43 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "@/shared/api"; +import type { InboxItem } from "@/shared/types"; + +export const inboxKeys = { + all: (wsId: string) => ["inbox", wsId] as const, + list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const, +}; + +export function inboxListOptions(wsId: string) { + return queryOptions({ + queryKey: inboxKeys.list(wsId), + queryFn: () => api.listInbox(), + }); +} + +/** + * Deduplicate inbox items by issue_id (one entry per issue, Linear-style). + * Exported for consumers to use in useMemo — not in queryOptions select + * (to avoid new array references on every cache update). + */ +export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] { + const active = items.filter((i) => !i.archived); + const groups = new Map(); + for (const item of active) { + const key = item.issue_id ?? item.id; + const group = groups.get(key) ?? []; + group.push(item); + groups.set(key, group); + } + const merged: InboxItem[] = []; + for (const group of groups.values()) { + group.sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); + if (group[0]) merged.push(group[0]); + } + return merged.sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); +} diff --git a/apps/web/core/inbox/ws-updaters.ts b/apps/web/core/inbox/ws-updaters.ts new file mode 100644 index 00000000..64c800ec --- /dev/null +++ b/apps/web/core/inbox/ws-updaters.ts @@ -0,0 +1,30 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { inboxKeys } from "./queries"; +import type { InboxItem, IssueStatus } from "@/shared/types"; + +export function onInboxNew( + qc: QueryClient, + wsId: string, + _item: InboxItem, +) { + // Use invalidateQueries instead of setQueryData — triggers a refetch that + // reliably notifies all observers. The inbox list is small so this is cheap. + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); +} + +export function onInboxIssueStatusChanged( + qc: QueryClient, + wsId: string, + issueId: string, + status: IssueStatus, +) { + qc.setQueryData(inboxKeys.list(wsId), (old) => + old?.map((i) => + i.issue_id === issueId ? { ...i, issue_status: status } : i, + ), + ); +} + +export function onInboxInvalidate(qc: QueryClient, wsId: string) { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); +} diff --git a/apps/web/core/index.ts b/apps/web/core/index.ts new file mode 100644 index 00000000..97d2430c --- /dev/null +++ b/apps/web/core/index.ts @@ -0,0 +1,3 @@ +export { createQueryClient } from "./query-client"; +export { QueryProvider } from "./provider"; +export { useWorkspaceId } from "./hooks"; diff --git a/apps/web/core/issues/index.ts b/apps/web/core/issues/index.ts new file mode 100644 index 00000000..a746aeb2 --- /dev/null +++ b/apps/web/core/issues/index.ts @@ -0,0 +1,28 @@ +export { + issueKeys, + issueListOptions, + issueDetailOptions, + issueTimelineOptions, + issueReactionsOptions, + issueSubscribersOptions, +} from "./queries"; + +export { + useCreateIssue, + useUpdateIssue, + useDeleteIssue, + useBatchUpdateIssues, + useBatchDeleteIssues, + useCreateComment, + useUpdateComment, + useDeleteComment, + useToggleCommentReaction, + useToggleIssueReaction, + useToggleIssueSubscriber, +} from "./mutations"; + +export { + onIssueCreated, + onIssueUpdated, + onIssueDeleted, +} from "./ws-updaters"; diff --git a/apps/web/core/issues/mutations.ts b/apps/web/core/issues/mutations.ts new file mode 100644 index 00000000..ff35facc --- /dev/null +++ b/apps/web/core/issues/mutations.ts @@ -0,0 +1,500 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/shared/api"; +import { issueKeys } from "./queries"; +import { useWorkspaceId } from "@core/hooks"; +import type { Issue, IssueReaction } from "@/shared/types"; +import type { + CreateIssueRequest, + UpdateIssueRequest, + ListIssuesResponse, +} from "@/shared/types"; +import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types"; + +// --------------------------------------------------------------------------- +// Issue CRUD +// --------------------------------------------------------------------------- + +export function useCreateIssue() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (data: CreateIssueRequest) => api.createIssue(data), + onSuccess: (newIssue) => { + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { ...old, issues: [...old.issues, newIssue], total: old.total + 1 } + : old, + ); + }, + }); +} + +export function useUpdateIssue() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) => + api.updateIssue(id, data), + onMutate: ({ id, ...data }) => { + // Fire-and-forget cancelQueries — keeps onMutate synchronous so the + // cache update happens in the same tick as mutate(). Awaiting would + // yield to the event loop, letting @dnd-kit reset its visual state + // before the optimistic update lands. + qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + const prevDetail = qc.getQueryData(issueKeys.detail(wsId, id)); + + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.map((i) => + i.id === id ? { ...i, ...data } : i, + ), + } + : old, + ); + qc.setQueryData(issueKeys.detail(wsId, id), (old) => + old ? { ...old, ...data } : old, + ); + return { prevList, prevDetail, id }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + if (ctx?.prevDetail) + qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail); + }, + onSettled: (_data, _err, vars) => { + qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + }, + }); +} + +export function useDeleteIssue() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (id: string) => api.deleteIssue(id), + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.filter((i) => i.id !== id), + total: old.total - 1, + } + : old, + ); + qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) }); + return { prevList }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + }, + }); +} + +export function useBatchUpdateIssues() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ + ids, + updates, + }: { + ids: string[]; + updates: UpdateIssueRequest; + }) => api.batchUpdateIssues(ids, updates), + onMutate: async ({ ids, updates }) => { + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.map((i) => + ids.includes(i.id) ? { ...i, ...updates } : i, + ), + } + : old, + ); + return { prevList }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + }, + }); +} + +export function useBatchDeleteIssues() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (ids: string[]) => api.batchDeleteIssues(ids), + onMutate: async (ids) => { + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.filter((i) => !ids.includes(i.id)), + total: old.total - ids.length, + } + : old, + ); + return { prevList }; + }, + onError: (_err, _ids, ctx) => { + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + }, + }); +} + +// --------------------------------------------------------------------------- +// Comments / Timeline +// --------------------------------------------------------------------------- + +export function useCreateComment(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + content, + type, + parentId, + attachmentIds, + }: { + content: string; + type?: string; + parentId?: string; + attachmentIds?: string[]; + }) => api.createComment(issueId, content, type, parentId, attachmentIds), + onSuccess: (comment) => { + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => { + if (!old) return old; + const entry: TimelineEntry = { + type: "comment", + id: comment.id, + actor_type: comment.author_type, + actor_id: comment.author_id, + content: comment.content, + parent_id: comment.parent_id, + comment_type: comment.type, + reactions: comment.reactions ?? [], + attachments: comment.attachments ?? [], + created_at: comment.created_at, + updated_at: comment.updated_at, + }; + if (old.some((e) => e.id === comment.id)) return old; + return [...old, entry]; + }, + ); + }, + }); +} + +export function useUpdateComment(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ commentId, content }: { commentId: string; content: string }) => + api.updateComment(commentId, content), + onMutate: async ({ commentId, content }) => { + await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) }); + const prev = qc.getQueryData(issueKeys.timeline(issueId)); + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => (e.id === commentId ? { ...e, content } : e)), + ); + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) + qc.setQueryData(issueKeys.timeline(issueId), ctx.prev); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) }); + }, + }); +} + +export function useDeleteComment(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (commentId: string) => api.deleteComment(commentId), + onMutate: async (commentId) => { + await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) }); + const prev = qc.getQueryData(issueKeys.timeline(issueId)); + + // Cascade: collect all child comment IDs + const toRemove = new Set([commentId]); + if (prev) { + let changed = true; + while (changed) { + changed = false; + for (const e of prev) { + if (e.parent_id && toRemove.has(e.parent_id) && !toRemove.has(e.id)) { + toRemove.add(e.id); + changed = true; + } + } + } + } + + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => old?.filter((e) => !toRemove.has(e.id)), + ); + return { prev }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prev) + qc.setQueryData(issueKeys.timeline(issueId), ctx.prev); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) }); + }, + }); +} + +export function useToggleCommentReaction(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ + commentId, + emoji, + existing, + }: { + commentId: string; + emoji: string; + existing: Reaction | undefined; + }) => { + if (existing) { + await api.removeReaction(commentId, emoji); + return null; + } + return api.addReaction(commentId, emoji); + }, + onMutate: async ({ commentId, emoji, existing }) => { + await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) }); + const prev = qc.getQueryData(issueKeys.timeline(issueId)); + + if (existing) { + // Remove + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => + e.id === commentId + ? { + ...e, + reactions: (e.reactions ?? []).filter( + (r) => r.id !== existing.id, + ), + } + : e, + ), + ); + } else { + // Add temp + const tempReaction: Reaction = { + id: `temp-${Date.now()}`, + comment_id: commentId, + actor_type: "", + actor_id: "", + emoji, + created_at: new Date().toISOString(), + }; + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => + e.id === commentId + ? { ...e, reactions: [...(e.reactions ?? []), tempReaction] } + : e, + ), + ); + } + return { prev }; + }, + onSuccess: (reaction, { commentId }) => { + if (reaction) { + // Replace temp with real + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => + e.id === commentId + ? { + ...e, + reactions: (e.reactions ?? []).map((r) => + r.id.startsWith("temp-") && r.emoji === reaction.emoji + ? reaction + : r, + ), + } + : e, + ), + ); + } + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) + qc.setQueryData(issueKeys.timeline(issueId), ctx.prev); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) }); + }, + }); +} + +// --------------------------------------------------------------------------- +// Issue-level Reactions +// --------------------------------------------------------------------------- + +export function useToggleIssueReaction(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ + emoji, + existing, + }: { + emoji: string; + existing: IssueReaction | undefined; + }) => { + if (existing) { + await api.removeIssueReaction(issueId, emoji); + return null; + } + return api.addIssueReaction(issueId, emoji); + }, + onMutate: async ({ emoji, existing }) => { + await qc.cancelQueries({ queryKey: issueKeys.reactions(issueId) }); + const prev = qc.getQueryData(issueKeys.reactions(issueId)); + + if (existing) { + qc.setQueryData( + issueKeys.reactions(issueId), + (old) => old?.filter((r) => r.id !== existing.id), + ); + } else { + const temp: IssueReaction = { + id: `temp-${Date.now()}`, + issue_id: issueId, + actor_type: "", + actor_id: "", + emoji, + created_at: new Date().toISOString(), + }; + qc.setQueryData( + issueKeys.reactions(issueId), + (old) => [...(old ?? []), temp], + ); + } + return { prev }; + }, + onSuccess: (reaction) => { + if (reaction) { + qc.setQueryData( + issueKeys.reactions(issueId), + (old) => + old?.map((r) => + r.id.startsWith("temp-") && r.emoji === reaction.emoji + ? reaction + : r, + ), + ); + } + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) + qc.setQueryData(issueKeys.reactions(issueId), ctx.prev); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) }); + }, + }); +} + +// --------------------------------------------------------------------------- +// Issue Subscribers +// --------------------------------------------------------------------------- + +export function useToggleIssueSubscriber(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ + userId, + userType, + subscribed, + }: { + userId: string; + userType: "member" | "agent"; + subscribed: boolean; + }) => { + if (subscribed) { + await api.unsubscribeFromIssue(issueId, userId, userType); + } else { + await api.subscribeToIssue(issueId, userId, userType); + } + }, + onMutate: async ({ userId, userType, subscribed }) => { + await qc.cancelQueries({ queryKey: issueKeys.subscribers(issueId) }); + const prev = qc.getQueryData( + issueKeys.subscribers(issueId), + ); + + if (subscribed) { + qc.setQueryData( + issueKeys.subscribers(issueId), + (old) => + old?.filter( + (s) => !(s.user_id === userId && s.user_type === userType), + ), + ); + } else { + const temp: IssueSubscriber = { + issue_id: issueId, + user_type: userType, + user_id: userId, + reason: "manual", + created_at: new Date().toISOString(), + }; + qc.setQueryData( + issueKeys.subscribers(issueId), + (old) => { + if ( + old?.some( + (s) => s.user_id === userId && s.user_type === userType, + ) + ) + return old; + return [...(old ?? []), temp]; + }, + ); + } + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) + qc.setQueryData(issueKeys.subscribers(issueId), ctx.prev); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) }); + }, + }); +} diff --git a/apps/web/core/issues/queries.ts b/apps/web/core/issues/queries.ts new file mode 100644 index 00000000..2d30d5a3 --- /dev/null +++ b/apps/web/core/issues/queries.ts @@ -0,0 +1,70 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "@/shared/api"; + +export const issueKeys = { + all: (wsId: string) => ["issues", wsId] as const, + list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const, + detail: (wsId: string, id: string) => + [...issueKeys.all(wsId), "detail", id] as const, + timeline: (issueId: string) => ["issues", "timeline", issueId] as const, + reactions: (issueId: string) => ["issues", "reactions", issueId] as const, + subscribers: (issueId: string) => + ["issues", "subscribers", issueId] as const, +}; + +const CLOSED_PAGE_SIZE = 50; + +/** + * CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total }), + * but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters + * must use setQueryData(...) — NOT setQueryData. + * + * Fetches all open issues + first page of closed issues (matching main's pagination strategy). + */ +export function issueListOptions(wsId: string) { + return queryOptions({ + queryKey: issueKeys.list(wsId), + queryFn: async () => { + const [openRes, closedRes] = await Promise.all([ + api.listIssues({ open_only: true }), + api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }), + ]); + return { + issues: [...openRes.issues, ...closedRes.issues], + total: openRes.total + closedRes.total, + }; + }, + select: (data) => data.issues, + }); +} + +export function issueDetailOptions(wsId: string, id: string) { + return queryOptions({ + queryKey: issueKeys.detail(wsId, 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), + }); +} diff --git a/apps/web/core/issues/ws-updaters.ts b/apps/web/core/issues/ws-updaters.ts new file mode 100644 index 00000000..7486c4fe --- /dev/null +++ b/apps/web/core/issues/ws-updaters.ts @@ -0,0 +1,56 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { issueKeys } from "./queries"; +import type { Issue } from "@/shared/types"; +import type { ListIssuesResponse } from "@/shared/types"; + +export function onIssueCreated( + qc: QueryClient, + wsId: string, + issue: Issue, +) { + qc.setQueryData(issueKeys.list(wsId), (old) => + old && !old.issues.some((i) => i.id === issue.id) + ? { ...old, issues: [...old.issues, issue], total: old.total + 1 } + : old, + ); +} + +export function onIssueUpdated( + qc: QueryClient, + wsId: string, + issue: Partial & { id: string }, +) { + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.map((i) => + i.id === issue.id ? { ...i, ...issue } : i, + ), + } + : old, + ); + qc.setQueryData(issueKeys.detail(wsId, issue.id), (old) => + old ? { ...old, ...issue } : old, + ); +} + +export function onIssueDeleted( + qc: QueryClient, + wsId: string, + issueId: string, +) { + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.filter((i) => i.id !== issueId), + total: old.total - 1, + } + : old, + ); + qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) }); + qc.removeQueries({ queryKey: issueKeys.timeline(issueId) }); + qc.removeQueries({ queryKey: issueKeys.reactions(issueId) }); + qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) }); +} diff --git a/apps/web/core/provider.tsx b/apps/web/core/provider.tsx new file mode 100644 index 00000000..41331d2e --- /dev/null +++ b/apps/web/core/provider.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useState } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { createQueryClient } from "./query-client"; +import type { ReactNode } from "react"; + +export function QueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState(createQueryClient); + return ( + + {children} + + + ); +} diff --git a/apps/web/core/query-client.ts b/apps/web/core/query-client.ts new file mode 100644 index 00000000..831a2e91 --- /dev/null +++ b/apps/web/core/query-client.ts @@ -0,0 +1,18 @@ +import { QueryClient } from "@tanstack/react-query"; + +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + gcTime: 10 * 60 * 1000, // 10 minutes + refetchOnWindowFocus: false, + refetchOnReconnect: true, + retry: 1, + }, + mutations: { + retry: false, + }, + }, + }); +} diff --git a/apps/web/core/runtimes/index.ts b/apps/web/core/runtimes/index.ts new file mode 100644 index 00000000..88f0524d --- /dev/null +++ b/apps/web/core/runtimes/index.ts @@ -0,0 +1 @@ +export { runtimeKeys, runtimeListOptions } from "./queries"; diff --git a/apps/web/core/runtimes/queries.ts b/apps/web/core/runtimes/queries.ts new file mode 100644 index 00000000..3cfb5aa4 --- /dev/null +++ b/apps/web/core/runtimes/queries.ts @@ -0,0 +1,14 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "@/shared/api"; + +export const runtimeKeys = { + all: (wsId: string) => ["runtimes", wsId] as const, + list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const, +}; + +export function runtimeListOptions(wsId: string) { + return queryOptions({ + queryKey: runtimeKeys.list(wsId), + queryFn: () => api.listRuntimes({ workspace_id: wsId }), + }); +} diff --git a/apps/web/core/workspace/index.ts b/apps/web/core/workspace/index.ts new file mode 100644 index 00000000..e5b264c2 --- /dev/null +++ b/apps/web/core/workspace/index.ts @@ -0,0 +1,13 @@ +export { + workspaceKeys, + workspaceListOptions, + memberListOptions, + agentListOptions, + skillListOptions, +} from "./queries"; + +export { + useCreateWorkspace, + useLeaveWorkspace, + useDeleteWorkspace, +} from "./mutations"; diff --git a/apps/web/core/workspace/mutations.ts b/apps/web/core/workspace/mutations.ts new file mode 100644 index 00000000..caf9cce6 --- /dev/null +++ b/apps/web/core/workspace/mutations.ts @@ -0,0 +1,34 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/shared/api"; +import { workspaceKeys } from "./queries"; + +export function useCreateWorkspace() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string; slug: string; description?: string }) => + api.createWorkspace(data), + onSettled: () => { + qc.invalidateQueries({ queryKey: workspaceKeys.list() }); + }, + }); +} + +export function useLeaveWorkspace() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId), + onSettled: () => { + qc.invalidateQueries({ queryKey: workspaceKeys.list() }); + }, + }); +} + +export function useDeleteWorkspace() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId), + onSettled: () => { + qc.invalidateQueries({ queryKey: workspaceKeys.list() }); + }, + }); +} diff --git a/apps/web/core/workspace/queries.ts b/apps/web/core/workspace/queries.ts new file mode 100644 index 00000000..e5f054d4 --- /dev/null +++ b/apps/web/core/workspace/queries.ts @@ -0,0 +1,39 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "@/shared/api"; + +export const workspaceKeys = { + all: (wsId: string) => ["workspaces", wsId] as const, + list: () => ["workspaces", "list"] as const, + members: (wsId: string) => ["workspaces", wsId, "members"] as const, + agents: (wsId: string) => ["workspaces", wsId, "agents"] as const, + skills: (wsId: string) => ["workspaces", wsId, "skills"] as const, +}; + +export function workspaceListOptions() { + return queryOptions({ + queryKey: workspaceKeys.list(), + queryFn: () => api.listWorkspaces(), + }); +} + +export function memberListOptions(wsId: string) { + return queryOptions({ + queryKey: workspaceKeys.members(wsId), + queryFn: () => api.listMembers(wsId), + }); +} + +export function agentListOptions(wsId: string) { + return queryOptions({ + queryKey: workspaceKeys.agents(wsId), + queryFn: () => + api.listAgents({ workspace_id: wsId, include_archived: true }), + }); +} + +export function skillListOptions(wsId: string) { + return queryOptions({ + queryKey: workspaceKeys.skills(wsId), + queryFn: () => api.listSkills(), + }); +} diff --git a/apps/web/features/editor/content-editor.tsx b/apps/web/features/editor/content-editor.tsx index 85e040d2..3e125115 100644 --- a/apps/web/features/editor/content-editor.tsx +++ b/apps/web/features/editor/content-editor.tsx @@ -34,6 +34,7 @@ import { import { useEditor, EditorContent } from "@tiptap/react"; import { cn } from "@/lib/utils"; import type { UploadResult } from "@/shared/hooks/use-file-upload"; +import { useQueryClient } from "@tanstack/react-query"; import { createEditorExtensions } from "./extensions"; import { uploadAndInsertFile } from "./extensions/file-upload"; import { preprocessMarkdown } from "./utils/preprocess"; @@ -94,6 +95,8 @@ const ContentEditor = forwardRef( onBlurRef.current = onBlur; onUploadFileRef.current = onUploadFile; + const queryClient = useQueryClient(); + const editor = useEditor({ immediatelyRender: false, editable, @@ -102,6 +105,7 @@ const ContentEditor = forwardRef( extensions: createEditorExtensions({ editable, placeholder: placeholderText, + queryClient, onSubmitRef, onUploadFileRef, }), diff --git a/apps/web/features/editor/extensions/index.ts b/apps/web/features/editor/extensions/index.ts index c182dd8a..4052c6d5 100644 --- a/apps/web/features/editor/extensions/index.ts +++ b/apps/web/features/editor/extensions/index.ts @@ -76,6 +76,7 @@ const ImageExtension = Image.extend({ export interface EditorExtensionsOptions { editable: boolean; placeholder?: string; + queryClient?: import("@tanstack/react-query").QueryClient; onSubmitRef?: RefObject<(() => void) | undefined>; onUploadFileRef?: RefObject< ((file: File) => Promise) | undefined @@ -107,7 +108,7 @@ export function createEditorExtensions( Markdown, BaseMentionExtension.configure({ HTMLAttributes: { class: "mention" }, - ...(editable ? { suggestion: createMentionSuggestion() } : {}), + ...(editable && options.queryClient ? { suggestion: createMentionSuggestion(options.queryClient) } : {}), }), ]; diff --git a/apps/web/features/editor/extensions/mention-suggestion.tsx b/apps/web/features/editor/extensions/mention-suggestion.tsx index 67e65346..1b9ed73e 100644 --- a/apps/web/features/editor/extensions/mention-suggestion.tsx +++ b/apps/web/features/editor/extensions/mention-suggestion.tsx @@ -10,8 +10,11 @@ import { } from "react"; import { ReactRenderer } from "@tiptap/react"; import { computePosition, offset, flip, shift } from "@floating-ui/dom"; +import type { QueryClient } from "@tanstack/react-query"; import { useWorkspaceStore } from "@/features/workspace"; -import { useIssueStore } from "@/features/issues"; +import { issueKeys } from "@core/issues/queries"; +import { workspaceKeys } from "@core/workspace/queries"; +import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@/shared/types"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { StatusIcon } from "@/features/issues/components/status-icon"; import { Badge } from "@/components/ui/badge"; @@ -210,14 +213,19 @@ function MentionRow({ // Suggestion config factory // --------------------------------------------------------------------------- -export function createMentionSuggestion(): Omit< +export function createMentionSuggestion(qc: QueryClient): Omit< SuggestionOptions, "editor" > { return { items: ({ query }) => { - const { members, agents } = useWorkspaceStore.getState(); - const { issues } = useIssueStore.getState(); + const wsId = useWorkspaceStore.getState().workspace?.id; + const members: MemberWithUser[] = wsId ? qc.getQueryData(workspaceKeys.members(wsId)) ?? [] : []; + const agents: Agent[] = wsId ? qc.getQueryData(workspaceKeys.agents(wsId)) ?? [] : []; + const issues: Issue[] = wsId + ? qc.getQueryData(issueKeys.list(wsId))?.issues ?? [] + : []; + const q = query.toLowerCase(); // Show "All members" option when query is empty or matches "all" diff --git a/apps/web/features/editor/extensions/mention-view.tsx b/apps/web/features/editor/extensions/mention-view.tsx index 0f62158d..b36be469 100644 --- a/apps/web/features/editor/extensions/mention-view.tsx +++ b/apps/web/features/editor/extensions/mention-view.tsx @@ -20,7 +20,9 @@ import { NodeViewWrapper } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react"; -import { useIssueStore } from "@/features/issues/store"; +import { useQuery } from "@tanstack/react-query"; +import { issueListOptions } from "@core/issues/queries"; +import { useWorkspaceId } from "@core/hooks"; import { StatusIcon } from "@/features/issues/components/status-icon"; export function MentionView({ node }: NodeViewProps) { @@ -48,7 +50,9 @@ function IssueMention({ issueId: string; fallbackLabel?: string; }) { - const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId)); + const wsId = useWorkspaceId(); + const { data: issues = [] } = useQuery(issueListOptions(wsId)); + const issue = issues.find((i) => i.id === issueId); const handleClick = (e: React.MouseEvent) => { e.preventDefault(); diff --git a/apps/web/features/inbox/index.ts b/apps/web/features/inbox/index.ts index a78bf0e5..e599b14c 100644 --- a/apps/web/features/inbox/index.ts +++ b/apps/web/features/inbox/index.ts @@ -1 +1,13 @@ -export { useInboxStore } from "./store"; +// Inbox server state is managed by TanStack Query. +// See core/inbox/ for queries, mutations, and WS updaters. +export { + inboxKeys, + inboxListOptions, + deduplicateInboxItems, + useMarkInboxRead, + useArchiveInbox, + useMarkAllInboxRead, + useArchiveAllInbox, + useArchiveAllReadInbox, + useArchiveCompletedInbox, +} from "@core/inbox"; diff --git a/apps/web/features/inbox/store.ts b/apps/web/features/inbox/store.ts deleted file mode 100644 index 0489a30b..00000000 --- a/apps/web/features/inbox/store.ts +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { create } from "zustand"; -import type { InboxItem, IssueStatus } from "@/shared/types"; -import { toast } from "sonner"; -import { api } from "@/shared/api"; -import { createLogger } from "@/shared/logger"; - -const logger = createLogger("inbox-store"); - -/** - * Deduplicate inbox items by issue_id (one entry per issue, Linear-style), - * keep latest, sort by time DESC. - * Memoized by reference — returns the same array if `items` hasn't changed. - */ -let _prevItems: InboxItem[] = []; -let _prevDeduped: InboxItem[] = []; - -function deduplicateInboxItems(items: InboxItem[]): InboxItem[] { - if (items === _prevItems) return _prevDeduped; - _prevItems = items; - - const active = items.filter((i) => !i.archived); - const groups = new Map(); - active.forEach((item) => { - const key = item.issue_id ?? item.id; - const group = groups.get(key) ?? []; - group.push(item); - groups.set(key, group); - }); - const merged: InboxItem[] = []; - groups.forEach((group) => { - const sorted = group.sort( - (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ); - if (sorted[0]) merged.push(sorted[0]); - }); - _prevDeduped = merged.sort( - (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ); - return _prevDeduped; -} - -interface InboxState { - items: InboxItem[]; - loading: boolean; - fetch: () => Promise; - setItems: (items: InboxItem[]) => void; - addItem: (item: InboxItem) => void; - markRead: (id: string) => void; - archive: (id: string) => void; - markAllRead: () => void; - archiveAll: () => void; - archiveAllRead: () => void; - updateIssueStatus: (issueId: string, status: IssueStatus) => void; - dedupedItems: () => InboxItem[]; - unreadCount: () => number; -} - -export const useInboxStore = create((set, get) => ({ - items: [], - loading: true, - - fetch: async () => { - logger.debug("fetch start"); - const isInitialLoad = get().items.length === 0; - if (isInitialLoad) set({ loading: true }); - try { - const data = await api.listInbox(); - logger.info("fetched", data.length, "items"); - set({ items: data, loading: false }); - } catch (err) { - logger.error("fetch failed", err); - toast.error("Failed to load inbox"); - if (isInitialLoad) set({ loading: false }); - } - }, - - setItems: (items) => set({ items }), - addItem: (item) => - set((s) => ({ - items: s.items.some((i) => i.id === item.id) - ? s.items - : [item, ...s.items], - })), - markRead: (id) => - set((s) => ({ - items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)), - })), - archive: (id) => - set((s) => { - const target = s.items.find((i) => i.id === id); - const issueId = target?.issue_id; - return { - items: s.items.map((i) => - i.id === id || (issueId && i.issue_id === issueId) - ? { ...i, archived: true } - : i, - ), - }; - }), - markAllRead: () => - set((s) => ({ - items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)), - })), - archiveAll: () => - set((s) => ({ - items: s.items.map((i) => (!i.archived ? { ...i, archived: true } : i)), - })), - archiveAllRead: () => - set((s) => ({ - items: s.items.map((i) => - i.read && !i.archived ? { ...i, archived: true } : i - ), - })), - updateIssueStatus: (issueId, status) => - set((s) => ({ - items: s.items.map((i) => - i.issue_id === issueId ? { ...i, issue_status: status } : i - ), - })), - dedupedItems: () => deduplicateInboxItems(get().items), - unreadCount: () => - get().dedupedItems().filter((i) => !i.read).length, -})); diff --git a/apps/web/features/issues/components/batch-action-toolbar.tsx b/apps/web/features/issues/components/batch-action-toolbar.tsx index 7ac9905f..4246433c 100644 --- a/apps/web/features/issues/components/batch-action-toolbar.tsx +++ b/apps/web/features/issues/components/batch-action-toolbar.tsx @@ -21,9 +21,8 @@ import { } from "@/components/ui/popover"; import type { UpdateIssueRequest } from "@/shared/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; -import { useIssueStore } from "@/features/issues/store"; import { useIssueSelectionStore } from "@/features/issues/stores/selection-store"; -import { api } from "@/shared/api"; +import { useBatchUpdateIssues, useBatchDeleteIssues } from "@core/issues/mutations"; import { StatusIcon } from "./status-icon"; import { PriorityIcon } from "./priority-icon"; import { AssigneePicker } from "./pickers"; @@ -37,42 +36,31 @@ export function BatchActionToolbar() { const [priorityOpen, setPriorityOpen] = useState(false); const [assigneeOpen, setAssigneeOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); - const [loading, setLoading] = useState(false); + const batchUpdate = useBatchUpdateIssues(); + const batchDelete = useBatchDeleteIssues(); + const loading = batchUpdate.isPending || batchDelete.isPending; if (count === 0) return null; const ids = Array.from(selectedIds); const handleBatchUpdate = async (updates: Partial) => { - setLoading(true); try { - await api.batchUpdateIssues(ids, updates); - for (const id of ids) { - useIssueStore.getState().updateIssue(id, updates); - } + await batchUpdate.mutateAsync({ ids, updates }); toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`); } catch { toast.error("Failed to update issues"); - useIssueStore.getState().fetch().catch(console.error); - } finally { - setLoading(false); } }; const handleBatchDelete = async () => { - setLoading(true); try { - await api.batchDeleteIssues(ids); - for (const id of ids) { - useIssueStore.getState().removeIssue(id); - } + await batchDelete.mutateAsync(ids); clear(); toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`); } catch { toast.error("Failed to delete issues"); - useIssueStore.getState().fetch().catch(console.error); } finally { - setLoading(false); setDeleteOpen(false); } }; diff --git a/apps/web/features/issues/components/board-card.tsx b/apps/web/features/issues/components/board-card.tsx index 25f4610b..7ffce0bf 100644 --- a/apps/web/features/issues/components/board-card.tsx +++ b/apps/web/features/issues/components/board-card.tsx @@ -2,14 +2,14 @@ import { useCallback, memo } from "react"; import Link from "next/link"; -import { useSortable } from "@dnd-kit/sortable"; +import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable"; +import type { AnimateLayoutChanges } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { toast } from "sonner"; import type { Issue, UpdateIssueRequest } from "@/shared/types"; import { CalendarDays } from "lucide-react"; import { ActorAvatar } from "@/components/common/actor-avatar"; -import { api } from "@/shared/api"; -import { useIssueStore } from "@/features/issues/store"; +import { useUpdateIssue } from "@core/issues/mutations"; import { PriorityIcon } from "./priority-icon"; import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers"; import { PRIORITY_CONFIG } from "@/features/issues/config"; @@ -46,16 +46,15 @@ export const BoardCardContent = memo(function BoardCardContent({ const storeProperties = useViewStore((s) => s.cardProperties); const priorityCfg = PRIORITY_CONFIG[issue.priority]; + const updateIssueMutation = useUpdateIssue(); const handleUpdate = useCallback( (updates: Partial) => { - const prev = { ...issue }; - useIssueStore.getState().updateIssue(issue.id, updates); - api.updateIssue(issue.id, updates).catch(() => { - useIssueStore.getState().updateIssue(issue.id, prev); - toast.error("Failed to update issue"); - }); + updateIssueMutation.mutate( + { id: issue.id, ...updates }, + { onError: () => toast.error("Failed to update issue") }, + ); }, - [issue], + [issue.id, updateIssueMutation], ); const showPriority = storeProperties.priority; @@ -168,6 +167,12 @@ export const BoardCardContent = memo(function BoardCardContent({ ); }); +const animateLayoutChanges: AnimateLayoutChanges = (args) => { + const { isSorting, wasDragging } = args; + if (isSorting || wasDragging) return false; + return defaultAnimateLayoutChanges(args); +}; + export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) { const { attributes, @@ -179,6 +184,7 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { } = useSortable({ id: issue.id, data: { status: issue.status }, + animateLayoutChanges, }); const style = { diff --git a/apps/web/features/issues/components/board-column.tsx b/apps/web/features/issues/components/board-column.tsx index 66d2808a..11bae0a5 100644 --- a/apps/web/features/issues/components/board-column.tsx +++ b/apps/web/features/issues/components/board-column.tsx @@ -15,32 +15,31 @@ import { } from "@/components/ui/dropdown-menu"; import { STATUS_CONFIG } from "@/features/issues/config"; import { useModalStore } from "@/features/modals"; -import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context"; -import { sortIssues } from "@/features/issues/utils/sort"; +import { useViewStoreApi } from "@/features/issues/stores/view-store-context"; import { StatusIcon } from "./status-icon"; import { DraggableBoardCard } from "./board-card"; export function BoardColumn({ status, - issues, + issueIds, + issueMap, }: { status: IssueStatus; - issues: Issue[]; + issueIds: string[]; + issueMap: Map; }) { const cfg = STATUS_CONFIG[status]; const { setNodeRef, isOver } = useDroppable({ id: status }); const viewStoreApi = useViewStoreApi(); - const sortBy = useViewStore((s) => s.sortBy); - const sortDirection = useViewStore((s) => s.sortDirection); - const sortedIssues = useMemo( - () => sortIssues(issues, sortBy, sortDirection), - [issues, sortBy, sortDirection] - ); - - const sortedIds = useMemo( - () => sortedIssues.map((i) => i.id), - [sortedIssues] + // Resolve IDs to Issue objects, preserving parent-provided order + const resolvedIssues = useMemo( + () => + issueIds.flatMap((id) => { + const issue = issueMap.get(id); + return issue ? [issue] : []; + }), + [issueIds, issueMap], ); return ( @@ -53,7 +52,7 @@ export function BoardColumn({ {cfg.label} - {issues.length} + {issueIds.length} @@ -97,12 +96,12 @@ export function BoardColumn({ isOver ? "bg-accent/60" : "" }`} > - - {sortedIssues.map((issue) => ( + + {resolvedIssues.map((issue) => ( ))} - {issues.length === 0 && ( + {issueIds.length === 0 && (

No issues

diff --git a/apps/web/features/issues/components/board-view.tsx b/apps/web/features/issues/components/board-view.tsx index a8038ed6..c6d42f1d 100644 --- a/apps/web/features/issues/components/board-view.tsx +++ b/apps/web/features/issues/components/board-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { DndContext, DragOverlay, @@ -12,7 +12,9 @@ import { type CollisionDetection, type DragStartEvent, type DragEndEvent, + type DragOverEvent, } from "@dnd-kit/core"; +import { arrayMove } from "@dnd-kit/sortable"; import { Eye, MoreHorizontal } from "lucide-react"; import type { Issue, IssueStatus } from "@/shared/types"; import { Button } from "@/components/ui/button"; @@ -23,7 +25,9 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config"; -import { useViewStoreApi } from "@/features/issues/stores/view-store-context"; +import { useViewStoreApi, useViewStore } from "@/features/issues/stores/view-store-context"; +import type { SortField, SortDirection } from "@/features/issues/stores/view-store"; +import { sortIssues } from "@/features/issues/utils/sort"; import { StatusIcon } from "./status-icon"; import { BoardColumn } from "./board-column"; import { BoardCardContent } from "./board-card"; @@ -44,13 +48,47 @@ const kanbanCollision: CollisionDetection = (args) => { return closestCenter(args); }; -/** Compute a float position to place an item at `targetIndex` within `siblings`. */ -function computePosition(siblings: Issue[], targetIndex: number): number { - if (siblings.length === 0) return 0; - if (targetIndex <= 0) return siblings[0]!.position - 1; - if (targetIndex >= siblings.length) - return siblings[siblings.length - 1]!.position + 1; - return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2; +/** Build column ID arrays from TQ issue data, respecting current sort. */ +function buildColumns( + issues: Issue[], + visibleStatuses: IssueStatus[], + sortBy: SortField, + sortDirection: SortDirection, +): Record { + const cols = {} as Record; + for (const status of visibleStatuses) { + const sorted = sortIssues( + issues.filter((i) => i.status === status), + sortBy, + sortDirection, + ); + cols[status] = sorted.map((i) => i.id); + } + return cols; +} + +/** Compute a float position for `activeId` based on its neighbors in `ids`. */ +function computePosition(ids: string[], activeId: string, issueMap: Map): number { + const idx = ids.indexOf(activeId); + if (idx === -1) return 0; + const getPos = (id: string) => issueMap.get(id)?.position ?? 0; + if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0; + if (idx === 0) return getPos(ids[1]!) - 1; + if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1; + return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2; +} + +/** Find which column (status) contains a given ID (issue or column droppable). */ +function findColumn( + columns: Record, + id: string, + visibleStatuses: IssueStatus[], +): IssueStatus | null { + if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus; + for (const [status, ids] of Object.entries(columns)) { + if (ids.includes(id)) return status as IssueStatus; + } + return null; } export function BoardView({ @@ -70,7 +108,52 @@ export function BoardView({ newPosition?: number ) => void; }) { + const sortBy = useViewStore((s) => s.sortBy); + const sortDirection = useViewStore((s) => s.sortDirection); + + // --- Drag state --- const [activeIssue, setActiveIssue] = useState(null); + const isDraggingRef = useRef(false); + + // --- Local columns state --- + // Between drags: follows TQ via useEffect. + // During drag: local-only, driven by onDragOver/onDragEnd. + const [columns, setColumns] = useState>(() => + buildColumns(issues, visibleStatuses, sortBy, sortDirection), + ); + const columnsRef = useRef(columns); + columnsRef.current = columns; + + useEffect(() => { + if (!isDraggingRef.current) { + setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection)); + } + }, [issues, visibleStatuses, sortBy, sortDirection]); + + // After a cross-column move, lock for one animation frame so dnd-kit's + // collision detection can stabilize before processing the next move. + // Without this, collision oscillates: A→B→A→B… until React bails out. + const recentlyMovedRef = useRef(false); + useEffect(() => { + const id = requestAnimationFrame(() => { + recentlyMovedRef.current = false; + }); + return () => cancelAnimationFrame(id); + }, [columns]); + + // --- Issue map --- + // Frozen during drag so BoardColumn/DraggableBoardCard props stay + // referentially stable even if a TQ refetch lands mid-drag. + const issueMap = useMemo(() => { + const map = new Map(); + for (const issue of issues) map.set(issue.id, issue); + return map; + }, [issues]); + + const issueMapRef = useRef(issueMap); + if (!isDraggingRef.current) { + issueMapRef.current = issueMap; + } const sensors = useSensors( useSensor(PointerSensor, { @@ -78,89 +161,100 @@ export function BoardView({ }) ); - // Pre-sort issues by position per status for position calculations - const issuesByStatus = useMemo(() => { - const map: Record = {}; - for (const status of visibleStatuses) { - map[status] = issues - .filter((i) => i.status === status) - .sort((a, b) => a.position - b.position); - } - return map; - }, [issues, visibleStatuses]); - const handleDragStart = useCallback( (event: DragStartEvent) => { - const issue = issues.find((i) => i.id === event.active.id); - if (issue) setActiveIssue(issue); + isDraggingRef.current = true; + const issue = issueMapRef.current.get(event.active.id as string) ?? null; + setActiveIssue(issue); }, - [issues] + [], + ); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + const { active, over } = event; + if (!over || recentlyMovedRef.current) return; + + const activeId = active.id as string; + const overId = over.id as string; + + setColumns((prev) => { + const activeCol = findColumn(prev, activeId, visibleStatuses); + const overCol = findColumn(prev, overId, visibleStatuses); + if (!activeCol || !overCol || activeCol === overCol) return prev; + + recentlyMovedRef.current = true; + const oldIds = prev[activeCol]!.filter((id) => id !== activeId); + const newIds = [...prev[overCol]!]; + const overIndex = newIds.indexOf(overId); + const insertIndex = overIndex >= 0 ? overIndex : newIds.length; + newIds.splice(insertIndex, 0, activeId); + return { ...prev, [activeCol]: oldIds, [overCol]: newIds }; + }); + }, + [visibleStatuses], ); const handleDragEnd = useCallback( (event: DragEndEvent) => { - setActiveIssue(null); const { active, over } = event; - if (!over || active.id === over.id) return; + isDraggingRef.current = false; + setActiveIssue(null); - const issueId = active.id as string; - const currentIssue = issues.find((i) => i.id === issueId); - if (!currentIssue) return; + const resetColumns = () => + setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection)); - // Determine target status - let targetStatus: IssueStatus; - let overIsColumn = false; - - if (visibleStatuses.includes(over.id as IssueStatus)) { - targetStatus = over.id as IssueStatus; - overIsColumn = true; - } else { - const targetIssue = issues.find((i) => i.id === over.id); - if (!targetIssue) return; - targetStatus = targetIssue.status; + if (!over) { + resetColumns(); + return; } - // Get sorted siblings in the target column (excluding the dragged item) - const siblings = (issuesByStatus[targetStatus] ?? []).filter( - (i) => i.id !== issueId - ); + const activeId = active.id as string; + const overId = over.id as string; - // Compute new position - let newPosition: number; + const cols = columnsRef.current; + const activeCol = findColumn(cols, activeId, visibleStatuses); + const overCol = findColumn(cols, overId, visibleStatuses); + if (!activeCol || !overCol) { + resetColumns(); + return; + } - if (overIsColumn) { - // Dropped on empty area of column → append to end - newPosition = computePosition(siblings, siblings.length); - } else { - // Dropped on a specific card → insert at that card's index - const overIndex = siblings.findIndex((i) => i.id === over.id); - if (overIndex === -1) { - newPosition = computePosition(siblings, siblings.length); - } else { - const isSameColumn = currentIssue.status === targetStatus; - const overIssuePosition = siblings[overIndex]!.position; - - if (isSameColumn && currentIssue.position < overIssuePosition) { - // Moving down → insert after the over card - newPosition = computePosition(siblings, overIndex + 1); - } else { - // Moving up or cross-column → insert before the over card - newPosition = computePosition(siblings, overIndex); - } + // Same-column reorder + let finalColumns = cols; + if (activeCol === overCol) { + const ids = cols[activeCol]!; + const oldIndex = ids.indexOf(activeId); + const newIndex = ids.indexOf(overId); + if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { + const reordered = arrayMove(ids, oldIndex, newIndex); + finalColumns = { ...cols, [activeCol]: reordered }; + setColumns(finalColumns); } } - // Skip if nothing changed + const finalCol = findColumn(finalColumns, activeId, visibleStatuses); + if (!finalCol) { + resetColumns(); + return; + } + + const map = issueMapRef.current; + const finalIds = finalColumns[finalCol]!; + const newPosition = computePosition(finalIds, activeId, map); + const currentIssue = map.get(activeId); + if ( - currentIssue.status === targetStatus && + currentIssue && + currentIssue.status === finalCol && currentIssue.position === newPosition ) { return; } - onMoveIssue(issueId, targetStatus, newPosition); + onMoveIssue(activeId, finalCol, newPosition); }, - [issues, issuesByStatus, onMoveIssue, visibleStatuses] + [issues, visibleStatuses, sortBy, sortDirection, onMoveIssue], ); return ( @@ -168,6 +262,7 @@ export function BoardView({ sensors={sensors} collisionDetection={kanbanCollision} onDragStart={handleDragStart} + onDragOver={handleDragOver} onDragEnd={handleDragEnd} >
@@ -175,7 +270,8 @@ export function BoardView({ i.status === status)} + issueIds={columns[status] ?? []} + issueMap={issueMapRef.current} /> ))} @@ -187,9 +283,9 @@ export function BoardView({ )}
- + {activeIssue ? ( -
+
) : null} diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 43c38885..50247ba7 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -63,10 +63,13 @@ import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent import { CommentCard } from "./comment-card"; import { CommentInput } from "./comment-input"; import { AgentLiveCard, TaskRunHistory } from "./agent-live-card"; -import { api } from "@/shared/api"; +import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; -import { useIssueStore } from "@/features/issues"; +import { useWorkspaceId } from "@core/hooks"; +import { issueListOptions, issueDetailOptions } from "@core/issues/queries"; +import { memberListOptions, agentListOptions } from "@core/workspace/queries"; +import { useUpdateIssue, useDeleteIssue } from "@core/issues/mutations"; import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline"; import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions"; import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers"; @@ -175,12 +178,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const router = useRouter(); const user = useAuthStore((s) => s.user); const workspace = useWorkspaceStore((s) => s.workspace); - const members = useWorkspaceStore((s) => s.members); - const agents = useWorkspaceStore((s) => s.agents); - const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role; - // Issue navigation - const allIssues = useIssueStore((s) => s.issues); + // Issue navigation — read from TQ list cache + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role; + const { data: allIssues = [] } = useQuery(issueListOptions(wsId)); const currentIndex = allIssues.findIndex((i) => i.id === id); const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null; const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null; @@ -200,38 +204,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const [highlightedId, setHighlightedId] = useState(null); const didHighlightRef = useRef(null); - // Single source of truth: read issue directly from global store - const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null; - const [issueLoading, setIssueLoading] = useState(!issue); - - // If issue isn't in the store yet, fetch and upsert it. - // loadedIdRef tracks which issue was already loaded — if it disappears - // from the store (workspace switch clears all issues), skip refetch. - const loadedIdRef = useRef(null); - useEffect(() => { - if (issue) { - loadedIdRef.current = id; - setIssueLoading(false); - return; - } - // Issue was loaded for this id but vanished → store cleared (workspace switch) - if (loadedIdRef.current === id) { - loadedIdRef.current = null; - return; - } - // Issue not in store → fetch it - setIssueLoading(true); - api - .getIssue(id) - .then((iss) => { - useIssueStore.getState().addIssue(iss); - }) - .catch((e) => { - console.error(e); - toast.error("Failed to load issue"); - }) - .finally(() => setIssueLoading(false)); - }, [id, !!issue]); + // Issue data from TQ — uses detail query, seeded from list cache if available + const { data: issue = null, isLoading: issueLoading } = useQuery({ + ...issueDetailOptions(wsId, id), + initialData: () => allIssues.find((i) => i.id === id), + }); // Custom hooks — encapsulate timeline, reactions, subscribers const { @@ -283,18 +260,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" }); }, []); - // Issue field updates — write directly to the global store (single source of truth) + // Issue field updates via TQ mutation (optimistic update + rollback in mutation hook) + const updateIssueMutation = useUpdateIssue(); const handleUpdateField = useCallback( (updates: Partial) => { if (!issue) return; - const prev = { ...issue }; - useIssueStore.getState().updateIssue(id, updates); - api.updateIssue(id, updates).catch(() => { - useIssueStore.getState().updateIssue(id, prev); - toast.error("Failed to update issue"); - }); + updateIssueMutation.mutate( + { id, ...updates }, + { onError: () => toast.error("Failed to update issue") }, + ); }, - [issue, id], + [issue, id, updateIssueMutation], ); const descEditorRef = useRef(null); @@ -303,11 +279,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo [uploadWithToast, id], ); + const deleteIssueMutation = useDeleteIssue(); const handleDelete = async () => { setDeleting(true); try { - await api.deleteIssue(issue!.id); - useIssueStore.getState().removeIssue(issue!.id); + await deleteIssueMutation.mutateAsync(issue!.id); toast.success("Issue deleted"); if (onDelete) onDelete(); else router.push("/issues"); diff --git a/apps/web/features/issues/components/issue-mention-card.tsx b/apps/web/features/issues/components/issue-mention-card.tsx index 9862fffe..f115c8d4 100644 --- a/apps/web/features/issues/components/issue-mention-card.tsx +++ b/apps/web/features/issues/components/issue-mention-card.tsx @@ -1,7 +1,9 @@ "use client"; import Link from "next/link"; -import { useIssueStore } from "@/features/issues/store"; +import { useQuery } from "@tanstack/react-query"; +import { issueListOptions } from "@core/issues/queries"; +import { useWorkspaceId } from "@core/hooks"; import { StatusIcon } from "./status-icon"; interface IssueMentionCardProps { @@ -11,7 +13,9 @@ interface IssueMentionCardProps { } export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) { - const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId)); + const wsId = useWorkspaceId(); + const { data: issues = [] } = useQuery(issueListOptions(wsId)); + const issue = issues.find((i) => i.id === issueId); if (!issue) { return ( diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index cdd25813..cdd95147 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -43,7 +43,9 @@ import { PRIORITY_CONFIG, } from "@/features/issues/config"; import { StatusIcon, PriorityIcon } from "@/features/issues/components"; -import { useWorkspaceStore } from "@/features/workspace"; +import { useQuery } from "@tanstack/react-query"; +import { useWorkspaceId } from "@core/hooks"; +import { memberListOptions, agentListOptions } from "@core/workspace/queries"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { useIssueViewStore, @@ -155,8 +157,9 @@ function ActorSubContent({ noAssigneeCount?: number; }) { const [search, setSearch] = useState(""); - const members = useWorkspaceStore((s) => s.members); - const agents = useWorkspaceStore((s) => s.agents); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); const query = search.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(query), diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx index 56a33580..45165384 100644 --- a/apps/web/features/issues/components/issues-page.tsx +++ b/apps/web/features/issues/components/issues-page.tsx @@ -5,7 +5,7 @@ import { toast } from "sonner"; import { ChevronRight, ListTodo } from "lucide-react"; import type { IssueStatus } from "@/shared/types"; import { Skeleton } from "@/components/ui/skeleton"; -import { useIssueStore } from "@/features/issues/store"; +import { useQuery } from "@tanstack/react-query"; import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store"; import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store"; import { ViewStoreProvider } from "@/features/issues/stores/view-store-context"; @@ -13,7 +13,9 @@ import { filterIssues } from "@/features/issues/utils/filter"; import { BOARD_STATUSES } from "@/features/issues/config"; import { useWorkspaceStore } from "@/features/workspace"; import { WorkspaceAvatar } from "@/features/workspace"; -import { api } from "@/shared/api"; +import { useWorkspaceId } from "@core/hooks"; +import { issueListOptions } from "@core/issues/queries"; +import { useUpdateIssue } from "@core/issues/mutations"; import { useIssueSelectionStore } from "@/features/issues/stores/selection-store"; import { IssuesHeader } from "./issues-header"; import { BoardView } from "./board-view"; @@ -21,8 +23,9 @@ import { ListView } from "./list-view"; import { BatchActionToolbar } from "./batch-action-toolbar"; export function IssuesPage() { - const allIssues = useIssueStore((s) => s.issues); - const loading = useIssueStore((s) => s.loading); + const wsId = useWorkspaceId(); + const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); + const workspace = useWorkspaceStore((s) => s.workspace); const scope = useIssuesScopeStore((s) => s.scope); const viewMode = useIssueViewStore((s) => s.viewMode); @@ -64,6 +67,7 @@ export function IssuesPage() { return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)); }, [visibleStatuses]); + const updateIssueMutation = useUpdateIssue(); const handleMoveIssue = useCallback( (issueId: string, newStatus: IssueStatus, newPosition?: number) => { // Auto-switch to manual sort so drag ordering is preserved @@ -78,14 +82,12 @@ export function IssuesPage() { }; if (newPosition !== undefined) updates.position = newPosition; - useIssueStore.getState().updateIssue(issueId, updates); - - api.updateIssue(issueId, updates).catch(() => { - toast.error("Failed to move issue"); - useIssueStore.getState().fetch().catch(console.error); - }); + updateIssueMutation.mutate( + { id: issueId, ...updates }, + { onError: () => toast.error("Failed to move issue") }, + ); }, - [] + [updateIssueMutation], ); if (loading) { diff --git a/apps/web/features/issues/components/pickers/assignee-picker.tsx b/apps/web/features/issues/components/pickers/assignee-picker.tsx index 3bc3a70f..fa34b4a0 100644 --- a/apps/web/features/issues/components/pickers/assignee-picker.tsx +++ b/apps/web/features/issues/components/pickers/assignee-picker.tsx @@ -3,8 +3,11 @@ import { useState } from "react"; import { Lock, UserMinus } from "lucide-react"; import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types"; +import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; -import { useWorkspaceStore, useActorName } from "@/features/workspace"; +import { useActorName } from "@/features/workspace"; +import { useWorkspaceId } from "@core/hooks"; +import { memberListOptions, agentListOptions } from "@core/workspace/queries"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { PropertyPicker, @@ -44,8 +47,9 @@ export function AssigneePicker({ const setOpen = controlledOnOpenChange ?? setInternalOpen; const [filter, setFilter] = useState(""); const user = useAuthStore((s) => s.user); - const members = useWorkspaceStore((s) => s.members); - const agents = useWorkspaceStore((s) => s.agents); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); const { getActorName } = useActorName(); const currentMember = members.find((m) => m.user_id === user?.id); diff --git a/apps/web/features/issues/hooks/use-issue-reactions.ts b/apps/web/features/issues/hooks/use-issue-reactions.ts index 3c824933..7e7418e7 100644 --- a/apps/web/features/issues/hooks/use-issue-reactions.ts +++ b/apps/web/features/issues/hooks/use-issue-reactions.ts @@ -1,38 +1,29 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { IssueReaction } from "@/shared/types"; import type { IssueReactionAddedPayload, IssueReactionRemovedPayload, } from "@/shared/types"; -import { api } from "@/shared/api"; -import { toast } from "sonner"; +import { issueReactionsOptions, issueKeys } from "@core/issues/queries"; +import { useToggleIssueReaction } from "@core/issues/mutations"; import { useWSEvent, useWSReconnect } from "@/features/realtime"; export function useIssueReactions(issueId: string, userId?: string) { - const [reactions, setReactions] = useState([]); - const [loading, setLoading] = useState(true); + const qc = useQueryClient(); + const { data: reactions = [], isLoading: loading } = useQuery( + issueReactionsOptions(issueId), + ); - // Initial fetch - useEffect(() => { - setReactions([]); - setLoading(true); - api - .getIssue(issueId) - .then((iss) => setReactions(iss.reactions ?? [])) - .catch((e) => { - console.error(e); - toast.error("Failed to load reactions"); - }) - .finally(() => setLoading(false)); - }, [issueId]); + const toggleMutation = useToggleIssueReaction(issueId); // Reconnect recovery useWSReconnect( useCallback(() => { - api.getIssue(issueId).then((iss) => setReactions(iss.reactions ?? [])).catch(console.error); - }, [issueId]), + qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) }); + }, [qc, issueId]), ); // --- WS event handlers --- @@ -43,13 +34,18 @@ export function useIssueReactions(issueId: string, userId?: string) { (payload: unknown) => { const { reaction, issue_id } = payload as IssueReactionAddedPayload; if (issue_id !== issueId) return; - if (reaction.actor_type === "member" && reaction.actor_id === userId) return; - setReactions((prev) => { - if (prev.some((r) => r.id === reaction.id)) return prev; - return [...prev, reaction]; - }); + if (reaction.actor_type === "member" && reaction.actor_id === userId) + return; + qc.setQueryData( + issueKeys.reactions(issueId), + (old) => { + if (!old) return old; + if (old.some((r) => r.id === reaction.id)) return old; + return [...old, reaction]; + }, + ); }, - [issueId, userId], + [qc, issueId, userId], ), ); @@ -60,13 +56,20 @@ export function useIssueReactions(issueId: string, userId?: string) { const p = payload as IssueReactionRemovedPayload; if (p.issue_id !== issueId) return; if (p.actor_type === "member" && p.actor_id === userId) return; - setReactions((prev) => - prev.filter( - (r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id), - ), + qc.setQueryData( + issueKeys.reactions(issueId), + (old) => + old?.filter( + (r) => + !( + r.emoji === p.emoji && + r.actor_type === p.actor_type && + r.actor_id === p.actor_id + ), + ), ); }, - [issueId, userId], + [qc, issueId, userId], ), ); @@ -76,36 +79,14 @@ export function useIssueReactions(issueId: string, userId?: string) { async (emoji: string) => { if (!userId) return; const existing = reactions.find( - (r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId, + (r) => + r.emoji === emoji && + r.actor_type === "member" && + r.actor_id === userId, ); - if (existing) { - setReactions((prev) => prev.filter((r) => r.id !== existing.id)); - try { - await api.removeIssueReaction(issueId, emoji); - } catch { - setReactions((prev) => [...prev, existing]); - toast.error("Failed to remove reaction"); - } - } else { - const temp: IssueReaction = { - id: `temp-${Date.now()}`, - issue_id: issueId, - actor_type: "member", - actor_id: userId, - emoji, - created_at: new Date().toISOString(), - }; - setReactions((prev) => [...prev, temp]); - try { - const reaction = await api.addIssueReaction(issueId, emoji); - setReactions((prev) => prev.map((r) => (r.id === temp.id ? reaction : r))); - } catch { - setReactions((prev) => prev.filter((r) => r.id !== temp.id)); - toast.error("Failed to add reaction"); - } - } + toggleMutation.mutate({ emoji, existing }); }, - [issueId, userId, reactions], + [userId, reactions, toggleMutation], ); return { reactions, loading, toggleReaction }; diff --git a/apps/web/features/issues/hooks/use-issue-subscribers.ts b/apps/web/features/issues/hooks/use-issue-subscribers.ts index 7c440900..c462e4a2 100644 --- a/apps/web/features/issues/hooks/use-issue-subscribers.ts +++ b/apps/web/features/issues/hooks/use-issue-subscribers.ts @@ -1,38 +1,29 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { IssueSubscriber } from "@/shared/types"; import type { SubscriberAddedPayload, SubscriberRemovedPayload, } from "@/shared/types"; -import { api } from "@/shared/api"; -import { toast } from "sonner"; +import { issueSubscribersOptions, issueKeys } from "@core/issues/queries"; +import { useToggleIssueSubscriber } from "@core/issues/mutations"; import { useWSEvent, useWSReconnect } from "@/features/realtime"; export function useIssueSubscribers(issueId: string, userId?: string) { - const [subscribers, setSubscribers] = useState([]); - const [loading, setLoading] = useState(true); + const qc = useQueryClient(); + const { data: subscribers = [], isLoading: loading } = useQuery( + issueSubscribersOptions(issueId), + ); - // Initial fetch - useEffect(() => { - setSubscribers([]); - setLoading(true); - api - .listIssueSubscribers(issueId) - .then((subs) => setSubscribers(subs)) - .catch((e) => { - console.error(e); - toast.error("Failed to load subscribers"); - }) - .finally(() => setLoading(false)); - }, [issueId]); + const toggleMutation = useToggleIssueSubscriber(issueId); // Reconnect recovery useWSReconnect( useCallback(() => { - api.listIssueSubscribers(issueId).then(setSubscribers).catch(console.error); - }, [issueId]), + qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) }); + }, [qc, issueId]), ); // --- WS event handlers --- @@ -43,21 +34,31 @@ export function useIssueSubscribers(issueId: string, userId?: string) { (payload: unknown) => { const p = payload as SubscriberAddedPayload; if (p.issue_id !== issueId) return; - setSubscribers((prev) => { - if (prev.some((s) => s.user_id === p.user_id && s.user_type === p.user_type)) return prev; - return [ - ...prev, - { - issue_id: p.issue_id, - user_type: p.user_type as "member" | "agent", - user_id: p.user_id, - reason: p.reason as IssueSubscriber["reason"], - created_at: new Date().toISOString(), - }, - ]; - }); + qc.setQueryData( + issueKeys.subscribers(issueId), + (old) => { + if (!old) return old; + if ( + old.some( + (s) => + s.user_id === p.user_id && s.user_type === p.user_type, + ) + ) + return old; + return [ + ...old, + { + issue_id: p.issue_id, + user_type: p.user_type as "member" | "agent", + user_id: p.user_id, + reason: p.reason as IssueSubscriber["reason"], + created_at: new Date().toISOString(), + }, + ]; + }, + ); }, - [issueId], + [qc, issueId], ), ); @@ -67,11 +68,16 @@ export function useIssueSubscribers(issueId: string, userId?: string) { (payload: unknown) => { const p = payload as SubscriberRemovedPayload; if (p.issue_id !== issueId) return; - setSubscribers((prev) => - prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type)), + qc.setQueryData( + issueKeys.subscribers(issueId), + (old) => + old?.filter( + (s) => + !(s.user_id === p.user_id && s.user_type === p.user_type), + ), ); }, - [issueId], + [qc, issueId], ), ); @@ -82,50 +88,29 @@ export function useIssueSubscribers(issueId: string, userId?: string) { ); const toggleSubscriber = useCallback( - async (subUserId: string, userType: "member" | "agent", currentlySubscribed: boolean) => { - if (currentlySubscribed) { - // Optimistic remove + rollback on error - const removed = subscribers.find( - (s) => s.user_id === subUserId && s.user_type === userType, - ); - setSubscribers((prev) => - prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType)), - ); - try { - await api.unsubscribeFromIssue(issueId, subUserId, userType); - } catch { - if (removed) setSubscribers((prev) => [...prev, removed]); - toast.error("Failed to update subscriber"); - } - } else { - // Optimistic add - const tempSub: IssueSubscriber = { - issue_id: issueId, - user_type: userType, - user_id: subUserId, - reason: "manual" as const, - created_at: new Date().toISOString(), - }; - setSubscribers((prev) => { - if (prev.some((s) => s.user_id === subUserId && s.user_type === userType)) return prev; - return [...prev, tempSub]; - }); - try { - await api.subscribeToIssue(issueId, subUserId, userType); - } catch { - setSubscribers((prev) => - prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType && s.reason === "manual")), - ); - toast.error("Failed to update subscriber"); - } - } + async ( + subUserId: string, + userType: "member" | "agent", + currentlySubscribed: boolean, + ) => { + toggleMutation.mutate({ + userId: subUserId, + userType, + subscribed: currentlySubscribed, + }); }, - [issueId, subscribers], + [toggleMutation], ); const toggleSubscribe = useCallback(() => { if (userId) toggleSubscriber(userId, "member", isSubscribed); }, [userId, isSubscribed, toggleSubscriber]); - return { subscribers, loading, isSubscribed, toggleSubscribe, toggleSubscriber }; + return { + subscribers, + loading, + isSubscribed, + toggleSubscribe, + toggleSubscriber, + }; } diff --git a/apps/web/features/issues/hooks/use-issue-timeline.ts b/apps/web/features/issues/hooks/use-issue-timeline.ts index 294c5bfb..fd303c17 100644 --- a/apps/web/features/issues/hooks/use-issue-timeline.ts +++ b/apps/web/features/issues/hooks/use-issue-timeline.ts @@ -1,7 +1,8 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import type { Comment, TimelineEntry } from "@/shared/types"; +import { useState, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Comment, TimelineEntry, Reaction } from "@/shared/types"; import type { CommentCreatedPayload, CommentUpdatedPayload, @@ -10,7 +11,13 @@ import type { ReactionAddedPayload, ReactionRemovedPayload, } from "@/shared/types"; -import { api } from "@/shared/api"; +import { issueTimelineOptions, issueKeys } from "@core/issues/queries"; +import { + useCreateComment, + useUpdateComment, + useDeleteComment, + useToggleCommentReaction, +} from "@core/issues/mutations"; import { useWSEvent, useWSReconnect } from "@/features/realtime"; import { toast } from "sonner"; @@ -30,29 +37,22 @@ function commentToTimelineEntry(c: Comment): TimelineEntry { } export function useIssueTimeline(issueId: string, userId?: string) { - const [timeline, setTimeline] = useState([]); + const qc = useQueryClient(); + const { data: timeline = [], isLoading: loading } = useQuery( + issueTimelineOptions(issueId), + ); const [submitting, setSubmitting] = useState(false); - const [loading, setLoading] = useState(true); - // Initial fetch + reset on id change - useEffect(() => { - setTimeline([]); - setLoading(true); - api - .listTimeline(issueId) - .then((entries) => setTimeline(entries)) - .catch((e) => { - console.error(e); - toast.error("Failed to load activity"); - }) - .finally(() => setLoading(false)); - }, [issueId]); + const createCommentMutation = useCreateComment(issueId); + const updateCommentMutation = useUpdateComment(issueId); + const deleteCommentMutation = useDeleteComment(issueId); + const toggleReactionMutation = useToggleCommentReaction(issueId); // Reconnect recovery useWSReconnect( useCallback(() => { - api.listTimeline(issueId).then(setTimeline).catch(console.error); - }, [issueId]), + qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) }); + }, [qc, issueId]), ); // --- WS event handlers --- @@ -63,13 +63,21 @@ export function useIssueTimeline(issueId: string, userId?: string) { (payload: unknown) => { const { comment } = payload as CommentCreatedPayload; if (comment.issue_id !== issueId) return; - if (comment.author_type === "member" && comment.author_id === userId) return; - setTimeline((prev) => { - if (prev.some((e) => e.id === comment.id)) return prev; - return [...prev, commentToTimelineEntry(comment)]; - }); + if ( + comment.author_type === "member" && + comment.author_id === userId + ) + return; + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => { + if (!old) return old; + if (old.some((e) => e.id === comment.id)) return old; + return [...old, commentToTimelineEntry(comment)]; + }, + ); }, - [issueId, userId], + [qc, issueId, userId], ), ); @@ -79,12 +87,16 @@ export function useIssueTimeline(issueId: string, userId?: string) { (payload: unknown) => { const { comment } = payload as CommentUpdatedPayload; if (comment.issue_id === issueId) { - setTimeline((prev) => - prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)), + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => + e.id === comment.id ? commentToTimelineEntry(comment) : e, + ), ); } }, - [issueId], + [qc, issueId], ), ); @@ -94,23 +106,31 @@ export function useIssueTimeline(issueId: string, userId?: string) { (payload: unknown) => { const { comment_id, issue_id } = payload as CommentDeletedPayload; if (issue_id === issueId) { - setTimeline((prev) => { - const idsToRemove = new Set([comment_id]); - let added = true; - while (added) { - added = false; - for (const e of prev) { - if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) { - idsToRemove.add(e.id); - added = true; + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => { + if (!old) return old; + const idsToRemove = new Set([comment_id]); + let added = true; + while (added) { + added = false; + for (const e of old) { + if ( + e.parent_id && + idsToRemove.has(e.parent_id) && + !idsToRemove.has(e.id) + ) { + idsToRemove.add(e.id); + added = true; + } } } - } - return prev.filter((e) => !idsToRemove.has(e.id)); - }); + return old.filter((e) => !idsToRemove.has(e.id)); + }, + ); } }, - [issueId], + [qc, issueId], ), ); @@ -122,12 +142,16 @@ export function useIssueTimeline(issueId: string, userId?: string) { if (p.issue_id !== issueId) return; const entry = p.entry; if (!entry || !entry.id) return; - setTimeline((prev) => { - if (prev.some((e) => e.id === entry.id)) return prev; - return [...prev, entry]; - }); + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => { + if (!old) return old; + if (old.some((e) => e.id === entry.id)) return old; + return [...old, entry]; + }, + ); }, - [issueId], + [qc, issueId], ), ); @@ -137,17 +161,23 @@ export function useIssueTimeline(issueId: string, userId?: string) { (payload: unknown) => { const { reaction, issue_id } = payload as ReactionAddedPayload; if (issue_id !== issueId) return; - if (reaction.actor_type === "member" && reaction.actor_id === userId) return; - setTimeline((prev) => - prev.map((e) => { - if (e.id !== reaction.comment_id) return e; - const existing = e.reactions ?? []; - if (existing.some((r) => r.id === reaction.id)) return e; - return { ...e, reactions: [...existing, reaction] }; - }), + if ( + reaction.actor_type === "member" && + reaction.actor_id === userId + ) + return; + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => { + if (e.id !== reaction.comment_id) return e; + const existing = e.reactions ?? []; + if (existing.some((r) => r.id === reaction.id)) return e; + return { ...e, reactions: [...existing, reaction] }; + }), ); }, - [issueId, userId], + [qc, issueId, userId], ), ); @@ -158,19 +188,26 @@ export function useIssueTimeline(issueId: string, userId?: string) { const p = payload as ReactionRemovedPayload; if (p.issue_id !== issueId) return; if (p.actor_type === "member" && p.actor_id === userId) return; - setTimeline((prev) => - prev.map((e) => { - if (e.id !== p.comment_id) return e; - return { - ...e, - reactions: (e.reactions ?? []).filter( - (r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id), - ), - }; - }), + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => { + if (e.id !== p.comment_id) return e; + return { + ...e, + reactions: (e.reactions ?? []).filter( + (r) => + !( + r.emoji === p.emoji && + r.actor_type === p.actor_type && + r.actor_id === p.actor_id + ), + ), + }; + }), ); }, - [issueId, userId], + [qc, issueId, userId], ), ); @@ -181,10 +218,9 @@ export function useIssueTimeline(issueId: string, userId?: string) { if (!content.trim() || submitting || !userId) return; setSubmitting(true); try { - const comment = await api.createComment(issueId, content, undefined, undefined, attachmentIds); - setTimeline((prev) => { - if (prev.some((e) => e.id === comment.id)) return prev; - return [...prev, commentToTimelineEntry(comment)]; + await createCommentMutation.mutateAsync({ + content, + attachmentIds, }); } catch { toast.error("Failed to send comment"); @@ -192,147 +228,61 @@ export function useIssueTimeline(issueId: string, userId?: string) { setSubmitting(false); } }, - [issueId, userId], + [userId, submitting, createCommentMutation], ); const submitReply = useCallback( async (parentId: string, content: string, attachmentIds?: string[]) => { if (!content.trim() || !userId) return; try { - const comment = await api.createComment(issueId, content, "comment", parentId, attachmentIds); - setTimeline((prev) => { - if (prev.some((e) => e.id === comment.id)) return prev; - return [...prev, commentToTimelineEntry(comment)]; + await createCommentMutation.mutateAsync({ + content, + type: "comment", + parentId, + attachmentIds, }); } catch { toast.error("Failed to send reply"); } }, - [issueId, userId], + [userId, createCommentMutation], ); const editComment = useCallback( async (commentId: string, content: string) => { - // Optimistic: update content immediately - let prevContent: string | undefined; - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - prevContent = e.content; - return { ...e, content, updated_at: new Date().toISOString() }; - }), - ); try { - const updated = await api.updateComment(commentId, content); - setTimeline((prev) => - prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)), - ); + await updateCommentMutation.mutateAsync({ commentId, content }); } catch { - // Rollback - if (prevContent !== undefined) { - setTimeline((prev) => - prev.map((e) => (e.id === commentId ? { ...e, content: prevContent! } : e)), - ); - } toast.error("Failed to update comment"); } }, - [], + [updateCommentMutation], ); const deleteComment = useCallback( async (commentId: string) => { - // Capture entries for rollback - let removedEntries: TimelineEntry[] = []; - setTimeline((prev) => { - const idsToRemove = new Set([commentId]); - let added = true; - while (added) { - added = false; - for (const e of prev) { - if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) { - idsToRemove.add(e.id); - added = true; - } - } - } - removedEntries = prev.filter((e) => idsToRemove.has(e.id)); - return prev.filter((e) => !idsToRemove.has(e.id)); - }); try { - await api.deleteComment(commentId); + await deleteCommentMutation.mutateAsync(commentId); } catch { - // Rollback: re-add removed entries - setTimeline((prev) => [...prev, ...removedEntries]); toast.error("Failed to delete comment"); } }, - [], + [deleteCommentMutation], ); const toggleReaction = useCallback( async (commentId: string, emoji: string) => { if (!userId) return; const entry = timeline.find((e) => e.id === commentId); - const existing = (entry?.reactions ?? []).find( - (r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId, + const existing: Reaction | undefined = (entry?.reactions ?? []).find( + (r) => + r.emoji === emoji && + r.actor_type === "member" && + r.actor_id === userId, ); - if (existing) { - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) }; - }), - ); - try { - await api.removeReaction(commentId, emoji); - } catch { - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - return { ...e, reactions: [...(e.reactions ?? []), existing] }; - }), - ); - toast.error("Failed to remove reaction"); - } - } else { - const tempReaction = { - id: `temp-${Date.now()}`, - comment_id: commentId, - actor_type: "member", - actor_id: userId, - emoji, - created_at: new Date().toISOString(), - }; - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - return { ...e, reactions: [...(e.reactions ?? []), tempReaction] }; - }), - ); - try { - const reaction = await api.addReaction(commentId, emoji); - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - return { - ...e, - reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)), - }; - }), - ); - } catch { - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) }; - }), - ); - toast.error("Failed to add reaction"); - } - } + toggleReactionMutation.mutate({ commentId, emoji, existing }); }, - [userId, timeline], + [userId, timeline, toggleReactionMutation], ); return { diff --git a/apps/web/features/issues/store.ts b/apps/web/features/issues/store.ts index 05add4f7..312ca9a6 100644 --- a/apps/web/features/issues/store.ts +++ b/apps/web/features/issues/store.ts @@ -1,97 +1,13 @@ "use client"; import { create } from "zustand"; -import type { Issue } from "@/shared/types"; -import { toast } from "sonner"; -import { api } from "@/shared/api"; -import { createLogger } from "@/shared/logger"; -const logger = createLogger("issue-store"); - -const CLOSED_PAGE_SIZE = 50; - -interface IssueState { - issues: Issue[]; - loading: boolean; +interface IssueClientState { activeIssueId: string | null; - hasMoreClosed: boolean; - closedOffset: number; - fetch: () => Promise; - fetchMoreClosed: () => Promise; - setIssues: (issues: Issue[]) => void; - addIssue: (issue: Issue) => void; - updateIssue: (id: string, updates: Partial) => void; - removeIssue: (id: string) => void; setActiveIssue: (id: string | null) => void; } -export const useIssueStore = create((set, get) => ({ - issues: [], - loading: true, +export const useIssueStore = create((set) => ({ activeIssueId: null, - hasMoreClosed: false, - closedOffset: 0, - - fetch: async () => { - logger.debug("fetch start"); - const isInitialLoad = get().issues.length === 0; - if (isInitialLoad) set({ loading: true }); - try { - // Phase 1: fetch ALL open issues (no limit) - // Phase 2: fetch first page of closed issues - const [openRes, closedRes] = await Promise.all([ - api.listIssues({ open_only: true }), - api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }), - ]); - const allIssues = [...openRes.issues, ...closedRes.issues]; - logger.info("fetched", openRes.issues.length, "open +", closedRes.issues.length, "closed issues"); - set({ - issues: allIssues, - loading: false, - hasMoreClosed: closedRes.issues.length >= CLOSED_PAGE_SIZE, - closedOffset: CLOSED_PAGE_SIZE, - }); - } catch (err) { - logger.error("fetch failed", err); - toast.error("Failed to load issues"); - if (isInitialLoad) set({ loading: false }); - } - }, - - fetchMoreClosed: async () => { - const { closedOffset } = get(); - try { - const res = await api.listIssues({ - status: "done", - limit: CLOSED_PAGE_SIZE, - offset: closedOffset, - }); - set((s) => ({ - issues: [ - ...s.issues, - ...res.issues.filter((ni) => !s.issues.some((ei) => ei.id === ni.id)), - ], - closedOffset: closedOffset + CLOSED_PAGE_SIZE, - hasMoreClosed: res.issues.length >= CLOSED_PAGE_SIZE, - })); - } catch (err) { - logger.error("fetchMoreClosed failed", err); - toast.error("Failed to load more issues"); - } - }, - - setIssues: (issues) => set({ issues }), - addIssue: (issue) => - set((s) => ({ - issues: s.issues.some((i) => i.id === issue.id) - ? s.issues - : [...s.issues, issue], - })), - updateIssue: (id, updates) => - set((s) => ({ - issues: s.issues.map((i) => (i.id === id ? { ...i, ...updates } : i)), - })), - removeIssue: (id) => - set((s) => ({ issues: s.issues.filter((i) => i.id !== id) })), setActiveIssue: (id) => set({ activeIssueId: id }), })); diff --git a/apps/web/features/modals/create-issue.tsx b/apps/web/features/modals/create-issue.tsx index 8dbf47dd..0fbf7744 100644 --- a/apps/web/features/modals/create-issue.tsx +++ b/apps/web/features/modals/create-issue.tsx @@ -30,9 +30,11 @@ import { TitleEditor } from "@/features/editor"; import { StatusIcon, PriorityIcon } from "@/features/issues/components"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; -import { useIssueStore } from "@/features/issues"; +import { useQuery } from "@tanstack/react-query"; +import { useWorkspaceId } from "@core/hooks"; +import { memberListOptions, agentListOptions } from "@core/workspace/queries"; import { useIssueDraftStore } from "@/features/issues/stores/draft-store"; -import { api } from "@/shared/api"; +import { useCreateIssue } from "@core/issues/mutations"; import { useFileUpload } from "@/shared/hooks/use-file-upload"; import { FileUploadButton } from "@/components/common/file-upload-button"; import { ActorAvatar } from "@/components/common/actor-avatar"; @@ -68,8 +70,9 @@ function PillButton({ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record | null }) { const router = useRouter(); const workspaceName = useWorkspaceStore((s) => s.workspace?.name); - const members = useWorkspaceStore((s) => s.members); - const agents = useWorkspaceStore((s) => s.agents); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); const { getActorName } = useActorName(); const draft = useIssueDraftStore((s) => s.draft); @@ -125,11 +128,12 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? }; const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); }; + const createIssueMutation = useCreateIssue(); const handleSubmit = async () => { if (!title.trim() || submitting) return; setSubmitting(true); try { - const issue = await api.createIssue({ + const issue = await createIssueMutation.mutateAsync({ title: title.trim(), description: descEditorRef.current?.getMarkdown()?.trim() || undefined, status, @@ -139,7 +143,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? due_date: dueDate || undefined, attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined, }); - useIssueStore.getState().addIssue(issue); clearDraft(); onClose(); toast.custom((t) => ( diff --git a/apps/web/features/my-issues/components/my-issues-page.tsx b/apps/web/features/my-issues/components/my-issues-page.tsx index ac7b13ba..b1fd926f 100644 --- a/apps/web/features/my-issues/components/my-issues-page.tsx +++ b/apps/web/features/my-issues/components/my-issues-page.tsx @@ -8,7 +8,8 @@ import type { IssueStatus } from "@/shared/types"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, WorkspaceAvatar } from "@/features/workspace"; -import { useIssueStore } from "@/features/issues/store"; +import { useQuery } from "@tanstack/react-query"; +import { agentListOptions } from "@core/workspace/queries"; import { filterIssues } from "@/features/issues/utils/filter"; import { BOARD_STATUSES } from "@/features/issues/config"; import { ViewStoreProvider } from "@/features/issues/stores/view-store-context"; @@ -17,16 +18,18 @@ import { BoardView } from "@/features/issues/components/board-view"; import { ListView } from "@/features/issues/components/list-view"; import { BatchActionToolbar } from "@/features/issues/components/batch-action-toolbar"; import { registerViewStoreForWorkspaceSync } from "@/features/issues/stores/view-store"; -import { api } from "@/shared/api"; +import { useWorkspaceId } from "@core/hooks"; +import { issueListOptions } from "@core/issues/queries"; +import { useUpdateIssue } from "@core/issues/mutations"; import { myIssuesViewStore } from "../stores/my-issues-view-store"; import { MyIssuesHeader } from "./my-issues-header"; export function MyIssuesPage() { const user = useAuthStore((s) => s.user); const workspace = useWorkspaceStore((s) => s.workspace); - const agents = useWorkspaceStore((s) => s.agents); - const allIssues = useIssueStore((s) => s.issues); - const loading = useIssueStore((s) => s.loading); + const wsId = useWorkspaceId(); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode); const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters); @@ -105,6 +108,7 @@ export function MyIssuesPage() { return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)); }, [visibleStatuses]); + const updateIssueMutation = useUpdateIssue(); const handleMoveIssue = useCallback( (issueId: string, newStatus: IssueStatus, newPosition?: number) => { const viewState = myIssuesViewStore.getState(); @@ -118,16 +122,12 @@ export function MyIssuesPage() { }; if (newPosition !== undefined) updates.position = newPosition; - useIssueStore.getState().updateIssue(issueId, updates); - - api.updateIssue(issueId, updates).catch(() => { - toast.error("Failed to move issue"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); - }); + updateIssueMutation.mutate( + { id: issueId, ...updates }, + { onError: () => toast.error("Failed to move issue") }, + ); }, - [], + [updateIssueMutation], ); if (loading) { diff --git a/apps/web/features/realtime/use-realtime-sync.ts b/apps/web/features/realtime/use-realtime-sync.ts index 85f66899..4302cc34 100644 --- a/apps/web/features/realtime/use-realtime-sync.ts +++ b/apps/web/features/realtime/use-realtime-sync.ts @@ -1,14 +1,21 @@ "use client"; import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import type { WSClient } from "@/shared/api"; import { toast } from "sonner"; -import { useIssueStore } from "@/features/issues"; -import { useInboxStore } from "@/features/inbox"; import { useWorkspaceStore } from "@/features/workspace"; import { useAuthStore } from "@/features/auth"; import { createLogger } from "@/shared/logger"; -import { api } from "@/shared/api"; +import { issueKeys } from "@core/issues/queries"; +import { + onIssueCreated, + onIssueUpdated, + onIssueDeleted, +} from "@core/issues/ws-updaters"; +import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "@core/inbox/ws-updaters"; +import { inboxKeys } from "@core/inbox/queries"; +import { workspaceKeys } from "@core/workspace/queries"; import type { MemberAddedPayload, WorkspaceDeletedPayload, @@ -33,33 +40,31 @@ const logger = createLogger("realtime-sync"); * by individual components via useWSEvent — not here. */ export function useRealtimeSync(ws: WSClient | null) { + const qc = useQueryClient(); // Main sync: onAny → refreshMap with debounce useEffect(() => { if (!ws) return; - // Event types handled by specific handlers below — skip generic refresh - const specificEvents = new Set([ - "issue:updated", "issue:created", "issue:deleted", "inbox:new", - ]); - const refreshMap: Record void> = { - inbox: () => void useInboxStore.getState().fetch(), - agent: () => void useWorkspaceStore.getState().refreshAgents(), - member: () => void useWorkspaceStore.getState().refreshMembers(), - workspace: () => { - // Lightweight: only re-fetch workspace list, don't hydrate everything. - // workspace:deleted is handled by a precise side-effect handler below. - api.listWorkspaces().then((wsList) => { - const current = useWorkspaceStore.getState().workspace; - const updated = current - ? wsList.find((w) => w.id === current.id) - : null; - if (updated) useWorkspaceStore.getState().updateWorkspace(updated); - }).catch((err) => { - logger.error("workspace refresh failed", err); - }); + inbox: () => { + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) onInboxInvalidate(qc, wsId); + }, + agent: () => { + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }); + }, + member: () => { + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) }); + }, + workspace: () => { + qc.invalidateQueries({ queryKey: workspaceKeys.list() }); + }, + skill: () => { + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }); }, - skill: () => void useWorkspaceStore.getState().refreshSkills(), }; const timers = new Map>(); @@ -75,6 +80,11 @@ export function useRealtimeSync(ws: WSClient | null) { ); }; + // Event types handled by specific handlers below — skip generic refresh + const specificEvents = new Set([ + "issue:updated", "issue:created", "issue:deleted", "inbox:new", + ]); + const unsubAny = ws.onAny((msg) => { const myUserId = useAuthStore.getState().user?.id; if (msg.actor_id && msg.actor_id === myUserId) { @@ -88,29 +98,40 @@ export function useRealtimeSync(ws: WSClient | null) { }); // --- Specific event handlers (granular updates, no full refetch) --- + // NOTE: ws.on() passes msg.payload (no actor_id). Self-event suppression + // requires WSClient changes to expose actor_id — tracked as separate task. const unsubIssueUpdated = ws.on("issue:updated", (p) => { const { issue } = p as IssueUpdatedPayload; if (!issue?.id) return; - useIssueStore.getState().updateIssue(issue.id, issue); - if (issue.status) { - useInboxStore.getState().updateIssueStatus(issue.id, issue.status); + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) { + onIssueUpdated(qc, wsId, issue); + if (issue.status) { + onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status); + } } }); const unsubIssueCreated = ws.on("issue:created", (p) => { const { issue } = p as IssueCreatedPayload; - if (issue) useIssueStore.getState().addIssue(issue); + if (!issue) return; + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) onIssueCreated(qc, wsId, issue); }); const unsubIssueDeleted = ws.on("issue:deleted", (p) => { const { issue_id } = p as IssueDeletedPayload; - if (issue_id) useIssueStore.getState().removeIssue(issue_id); + if (!issue_id) return; + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) onIssueDeleted(qc, wsId, issue_id); }); const unsubInboxNew = ws.on("inbox:new", (p) => { const { item } = p as InboxNewPayload; - if (item) useInboxStore.getState().addItem(item); + if (!item) return; + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) onInboxNew(qc, wsId, item); }); // --- Side-effect handlers (toast, navigation) --- @@ -158,7 +179,7 @@ export function useRealtimeSync(ws: WSClient | null) { timers.forEach(clearTimeout); timers.clear(); }; - }, [ws]); + }, [ws, qc]); // Reconnect → refetch all data to recover missed events useEffect(() => { @@ -167,18 +188,20 @@ export function useRealtimeSync(ws: WSClient | null) { const unsub = ws.onReconnect(async () => { logger.info("reconnected, refetching all data"); try { - await Promise.all([ - useIssueStore.getState().fetch(), - useInboxStore.getState().fetch(), - useWorkspaceStore.getState().refreshAgents(), - useWorkspaceStore.getState().refreshMembers(), - useWorkspaceStore.getState().refreshSkills(), - ]); + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) { + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }); + qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) }); + qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }); + } + qc.invalidateQueries({ queryKey: workspaceKeys.list() }); } catch (e) { logger.error("reconnect refetch failed", e); } }); return unsub; - }, [ws]); + }, [ws, qc]); } diff --git a/apps/web/features/runtimes/components/runtimes-page.tsx b/apps/web/features/runtimes/components/runtimes-page.tsx index 7a87ee21..65f54222 100644 --- a/apps/web/features/runtimes/components/runtimes-page.tsx +++ b/apps/web/features/runtimes/components/runtimes-page.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useCallback } from "react"; +import { useState, useCallback } from "react"; import { Server } from "lucide-react"; import { useDefaultLayout } from "react-resizable-panels"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ResizablePanelGroup, ResizablePanel, @@ -10,38 +11,35 @@ import { } from "@/components/ui/resizable"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuthStore } from "@/features/auth"; -import { useWorkspaceStore } from "@/features/workspace"; +import { useWorkspaceId } from "@core/hooks"; +import { runtimeListOptions, runtimeKeys } from "@core/runtimes/queries"; import { useWSEvent } from "@/features/realtime"; -import { useRuntimeStore } from "../store"; import { RuntimeList } from "./runtime-list"; import { RuntimeDetail } from "./runtime-detail"; export default function RuntimesPage() { const isLoading = useAuthStore((s) => s.isLoading); - const workspace = useWorkspaceStore((s) => s.workspace); - const runtimes = useRuntimeStore((s) => s.runtimes); - const selectedId = useRuntimeStore((s) => s.selectedId); - const fetching = useRuntimeStore((s) => s.fetching); - const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes); - const setSelectedId = useRuntimeStore((s) => s.setSelectedId); + const wsId = useWorkspaceId(); + const qc = useQueryClient(); + const { data: runtimes = [], isLoading: fetching } = useQuery(runtimeListOptions(wsId)); + const [selectedId, setSelectedId] = useState(""); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_runtimes_layout", }); - useEffect(() => { - if (workspace) fetchRuntimes(); - }, [workspace, fetchRuntimes]); - // Re-fetch on daemon register/deregister events. - // Heartbeat events are not broadcast over WS, so no handler needed. const handleDaemonEvent = useCallback(() => { - fetchRuntimes(); - }, [fetchRuntimes]); + qc.invalidateQueries({ queryKey: runtimeKeys.list(wsId) }); + }, [qc, wsId]); useWSEvent("daemon:register", handleDaemonEvent); - const selected = runtimes.find((r) => r.id === selectedId) ?? null; + // Auto-select first runtime if nothing selected + const effectiveSelectedId = selectedId && runtimes.some((r) => r.id === selectedId) + ? selectedId + : runtimes[0]?.id ?? ""; + const selected = runtimes.find((r) => r.id === effectiveSelectedId) ?? null; if (isLoading || fetching) { return ( @@ -95,7 +93,7 @@ export default function RuntimesPage() { > diff --git a/apps/web/features/runtimes/index.ts b/apps/web/features/runtimes/index.ts index c24959ba..5fa5b0bf 100644 --- a/apps/web/features/runtimes/index.ts +++ b/apps/web/features/runtimes/index.ts @@ -1,2 +1 @@ export { RuntimesPage } from "./components"; -export { useRuntimeStore } from "./store"; diff --git a/apps/web/features/runtimes/store.ts b/apps/web/features/runtimes/store.ts deleted file mode 100644 index 03e9f716..00000000 --- a/apps/web/features/runtimes/store.ts +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { create } from "zustand"; -import type { AgentRuntime } from "@/shared/types"; -import { api } from "@/shared/api"; -import { useWorkspaceStore } from "@/features/workspace"; - -interface RuntimeState { - runtimes: AgentRuntime[]; - selectedId: string; - fetching: boolean; -} - -interface RuntimeActions { - fetchRuntimes: () => Promise; - setSelectedId: (id: string) => void; - /** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */ - patchRuntime: (id: string, updates: Partial) => void; - /** Replace the full runtimes list (used on daemon:register events). */ - setRuntimes: (runtimes: AgentRuntime[]) => void; -} - -type RuntimeStore = RuntimeState & RuntimeActions; - -export const useRuntimeStore = create((set, get) => ({ - // State - runtimes: [], - selectedId: "", - fetching: true, - - // Actions - fetchRuntimes: async () => { - const workspace = useWorkspaceStore.getState().workspace; - if (!workspace) return; - try { - const data = await api.listRuntimes({ workspace_id: workspace.id }); - const { selectedId } = get(); - set({ - runtimes: data, - fetching: false, - // Auto-select first if nothing selected - selectedId: selectedId && data.some((r) => r.id === selectedId) - ? selectedId - : data[0]?.id ?? "", - }); - } catch { - set({ fetching: false }); - } - }, - - setSelectedId: (id) => set({ selectedId: id }), - - patchRuntime: (id, updates) => { - set((state) => ({ - runtimes: state.runtimes.map((r) => - r.id === id ? { ...r, ...updates } : r, - ), - })); - }, - - setRuntimes: (runtimes) => { - const { selectedId } = get(); - set({ - runtimes, - selectedId: selectedId && runtimes.some((r) => r.id === selectedId) - ? selectedId - : runtimes[0]?.id ?? "", - }); - }, -})); diff --git a/apps/web/features/skills/components/skills-page.tsx b/apps/web/features/skills/components/skills-page.tsx index 645d7428..d2428d40 100644 --- a/apps/web/features/skills/components/skills-page.tsx +++ b/apps/web/features/skills/components/skills-page.tsx @@ -33,8 +33,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { toast } from "sonner"; import { Skeleton } from "@/components/ui/skeleton"; import { api } from "@/shared/api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; -import { useWorkspaceStore } from "@/features/workspace"; +import { useWorkspaceId } from "@core/hooks"; +import { skillListOptions, workspaceKeys } from "@core/workspace/queries"; import { FileTree } from "./file-tree"; import { FileViewer } from "./file-viewer"; @@ -346,6 +348,8 @@ function SkillDetail({ onUpdate: (id: string, data: UpdateSkillRequest) => Promise; onDelete: (id: string) => Promise; }) { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); const [name, setName] = useState(skill.name); const [description, setDescription] = useState(skill.description); const [content, setContent] = useState(skill.content); @@ -370,12 +374,12 @@ function SkillDetail({ setSelectedPath(SKILL_MD); setLoadingFiles(true); api.getSkill(skill.id).then((full) => { - useWorkspaceStore.getState().upsertSkill(full); + qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }); setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content }))); }).catch((e) => { toast.error(e instanceof Error ? e.message : "Failed to load skill files"); }).finally(() => setLoadingFiles(false)); - }, [skill.id]); + }, [skill.id, qc, wsId]); // Build the virtual file map const fileMap = useMemo(() => buildFileMap(content, files), [content, files]); @@ -610,10 +614,9 @@ function SkillDetail({ export default function SkillsPage() { const isLoading = useAuthStore((s) => s.isLoading); - const skills = useWorkspaceStore((s) => s.skills); - const refreshSkills = useWorkspaceStore((s) => s.refreshSkills); - const upsertSkill = useWorkspaceStore((s) => s.upsertSkill); - const removeSkill = useWorkspaceStore((s) => s.removeSkill); + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + const { data: skills = [] } = useQuery(skillListOptions(wsId)); const [selectedId, setSelectedId] = useState(""); const [showCreate, setShowCreate] = useState(false); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ @@ -628,22 +631,22 @@ export default function SkillsPage() { const handleCreate = async (data: CreateSkillRequest) => { const skill = await api.createSkill(data); - upsertSkill(skill); + qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }); setSelectedId(skill.id); toast.success("Skill created"); }; const handleImport = async (url: string) => { const skill = await api.importSkill({ url }); - upsertSkill(skill); + qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }); setSelectedId(skill.id); toast.success("Skill imported"); }; const handleUpdate = async (id: string, data: UpdateSkillRequest) => { try { - const updated = await api.updateSkill(id, data); - upsertSkill(updated); + await api.updateSkill(id, data); + qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }); toast.success("Skill saved"); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to save skill"); @@ -658,7 +661,7 @@ export default function SkillsPage() { const remaining = skills.filter((s) => s.id !== id); setSelectedId(remaining[0]?.id ?? ""); } - removeSkill(id); + qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }); toast.success("Skill deleted"); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to delete skill"); diff --git a/apps/web/features/workspace/hooks.ts b/apps/web/features/workspace/hooks.ts index b1e062d9..b0513e90 100644 --- a/apps/web/features/workspace/hooks.ts +++ b/apps/web/features/workspace/hooks.ts @@ -1,10 +1,13 @@ "use client"; -import { useWorkspaceStore } from "./store"; +import { useQuery } from "@tanstack/react-query"; +import { useWorkspaceId } from "@core/hooks"; +import { memberListOptions, agentListOptions } from "@core/workspace/queries"; export function useActorName() { - const members = useWorkspaceStore((s) => s.members); - const agents = useWorkspaceStore((s) => s.agents); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); const getMemberName = (userId: string) => { const m = members.find((m) => m.user_id === userId); diff --git a/apps/web/features/workspace/store.ts b/apps/web/features/workspace/store.ts index 0c6f8523..4d7ab3b2 100644 --- a/apps/web/features/workspace/store.ts +++ b/apps/web/features/workspace/store.ts @@ -1,10 +1,7 @@ "use client"; import { create } from "zustand"; -import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types"; -import { useIssueStore } from "@/features/issues"; -import { useInboxStore } from "@/features/inbox"; -import { useRuntimeStore } from "@/features/runtimes"; +import type { Workspace } from "@/shared/types"; import { toast } from "sonner"; import { api } from "@/shared/api"; import { createLogger } from "@/shared/logger"; @@ -14,30 +11,21 @@ const logger = createLogger("workspace-store"); interface WorkspaceState { workspace: Workspace | null; workspaces: Workspace[]; - members: MemberWithUser[]; - agents: Agent[]; - skills: Skill[]; } interface WorkspaceActions { hydrateWorkspace: ( wsList: Workspace[], preferredWorkspaceId?: string | null, - ) => Promise; - switchWorkspace: (workspaceId: string) => Promise; + ) => Workspace | null; + switchWorkspace: (workspaceId: string) => void; refreshWorkspaces: () => Promise; - refreshMembers: () => Promise; - updateAgent: (id: string, updates: Partial) => void; - refreshAgents: () => Promise; - refreshSkills: () => Promise; - upsertSkill: (skill: Skill) => void; - removeSkill: (id: string) => void; + updateWorkspace: (ws: Workspace) => void; createWorkspace: (data: { name: string; slug: string; description?: string; }) => Promise; - updateWorkspace: (ws: Workspace) => void; leaveWorkspace: (workspaceId: string) => Promise; deleteWorkspace: (workspaceId: string) => Promise; clearWorkspace: () => void; @@ -49,12 +37,9 @@ export const useWorkspaceStore = create((set, get) => ({ // State workspace: null, workspaces: [], - members: [], - agents: [], - skills: [], // Actions - hydrateWorkspace: async (wsList, preferredWorkspaceId) => { + hydrateWorkspace: (wsList, preferredWorkspaceId) => { set({ workspaces: wsList }); const nextWorkspace = @@ -67,56 +52,35 @@ export const useWorkspaceStore = create((set, get) => ({ if (!nextWorkspace) { api.setWorkspaceId(null); localStorage.removeItem("multica_workspace_id"); - set({ workspace: null, members: [], agents: [], skills: [] }); + set({ workspace: null }); return null; } api.setWorkspaceId(nextWorkspace.id); localStorage.setItem("multica_workspace_id", nextWorkspace.id); set({ workspace: nextWorkspace }); - logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id); - const [nextMembers, nextAgents, nextSkills] = await Promise.all([ - api.listMembers(nextWorkspace.id).catch((e) => { - logger.error("failed to load members", e); - toast.error("Failed to load members"); - return [] as MemberWithUser[]; - }), - api.listAgents({ workspace_id: nextWorkspace.id, include_archived: true }).catch((e) => { - logger.error("failed to load agents", e); - toast.error("Failed to load agents"); - return [] as Agent[]; - }), - api.listSkills().catch(() => [] as Skill[]), - useIssueStore.getState().fetch().catch(() => {}), - useInboxStore.getState().fetch().catch(() => {}), - ]); - logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length); - set({ members: nextMembers, agents: nextAgents, skills: nextSkills }); + + // Members, agents, skills, issues, inbox are all managed by TanStack Query. + // They auto-fetch when components mount with the workspace ID in their query key. return nextWorkspace; }, - switchWorkspace: async (workspaceId) => { + switchWorkspace: (workspaceId) => { logger.info("switching to", workspaceId); const { workspaces, hydrateWorkspace } = get(); const ws = workspaces.find((item) => item.id === workspaceId); if (!ws) return; - // Switch identity FIRST — api client, localStorage, and the - // workspace object in this store — so that any in-flight refetch - // (e.g. triggered by a WS event during the async gap) already - // targets the new workspace. api.setWorkspaceId(ws.id); localStorage.setItem("multica_workspace_id", ws.id); - // Clear ALL stale data across every store before hydrating. - useIssueStore.getState().setIssues([]); - useInboxStore.getState().setItems([]); - useRuntimeStore.getState().setRuntimes([]); - set({ workspace: ws, members: [], agents: [], skills: [] }); + // All data caches (issues, inbox, members, agents, skills, runtimes) + // are managed by TanStack Query, keyed by wsId — auto-refetch on switch. + set({ workspace: ws }); - await hydrateWorkspace(workspaces, ws.id); + hydrateWorkspace(workspaces, ws.id); }, refreshWorkspaces: async () => { @@ -124,7 +88,7 @@ export const useWorkspaceStore = create((set, get) => ({ const storedWorkspaceId = localStorage.getItem("multica_workspace_id"); try { const wsList = await api.listWorkspaces(); - await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId); + hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId); return wsList; } catch (e) { logger.error("failed to refresh workspaces", e); @@ -133,77 +97,6 @@ export const useWorkspaceStore = create((set, get) => ({ } }, - refreshMembers: async () => { - const { workspace } = get(); - if (!workspace) return; - try { - const members = await api.listMembers(workspace.id); - set({ members }); - } catch (e) { - logger.error("failed to refresh members", e); - toast.error("Failed to refresh members"); - } - }, - - updateAgent: (id, updates) => - set((s) => ({ - agents: s.agents.map((a) => (a.id === id ? { ...a, ...updates } : a)), - })), - - refreshAgents: async () => { - const { workspace } = get(); - if (!workspace) return; - try { - const agents = await api.listAgents({ workspace_id: workspace.id, include_archived: true }); - set({ agents }); - } catch (e) { - logger.error("failed to refresh agents", e); - toast.error("Failed to refresh agents"); - } - }, - - refreshSkills: async () => { - const { workspace, skills: existing } = get(); - if (!workspace) return; - try { - const fetched = await api.listSkills(); - // listSkills doesn't include files — preserve files from existing entries - const filesById = new Map( - existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]), - ); - const merged = fetched.map((s) => ({ - ...s, - files: s.files ?? filesById.get(s.id) ?? [], - })); - set({ skills: merged }); - } catch (e) { - logger.error("failed to refresh skills", e); - toast.error("Failed to refresh skills"); - } - }, - - upsertSkill: (skill) => { - set((state) => { - const idx = state.skills.findIndex((s) => s.id === skill.id); - if (idx >= 0) { - const next = [...state.skills]; - next[idx] = skill; - return { skills: next }; - } - return { skills: [...state.skills, skill] }; - }); - }, - - removeSkill: (id) => { - set((state) => ({ skills: state.skills.filter((s) => s.id !== id) })); - }, - - createWorkspace: async (data) => { - const ws = await api.createWorkspace(data); - set((state) => ({ workspaces: [...state.workspaces, ws] })); - return ws; - }, - updateWorkspace: (ws) => { set((state) => ({ workspace: state.workspace?.id === ws.id ? ws : state.workspace, @@ -213,13 +106,19 @@ export const useWorkspaceStore = create((set, get) => ({ })); }, + createWorkspace: async (data) => { + const ws = await api.createWorkspace(data); + set((state) => ({ workspaces: [...state.workspaces, ws] })); + return ws; + }, + leaveWorkspace: async (workspaceId) => { await api.leaveWorkspace(workspaceId); const { workspace, hydrateWorkspace } = get(); const wsList = await api.listWorkspaces(); const preferredWorkspaceId = workspace?.id === workspaceId ? null : (workspace?.id ?? null); - await hydrateWorkspace(wsList, preferredWorkspaceId); + hydrateWorkspace(wsList, preferredWorkspaceId); }, deleteWorkspace: async (workspaceId) => { @@ -228,11 +127,11 @@ export const useWorkspaceStore = create((set, get) => ({ const wsList = await api.listWorkspaces(); const preferredWorkspaceId = workspace?.id === workspaceId ? null : (workspace?.id ?? null); - await hydrateWorkspace(wsList, preferredWorkspaceId); + hydrateWorkspace(wsList, preferredWorkspaceId); }, clearWorkspace: () => { api.setWorkspaceId(null); - set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] }); + set({ workspace: null, workspaces: [] }); }, })); diff --git a/apps/web/package.json b/apps/web/package.json index 3e777288..a0e0ec52 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,11 +18,12 @@ "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@floating-ui/dom": "^1.7.6", + "@tanstack/react-query": "^5.96.2", + "@tanstack/react-query-devtools": "^5.96.2", "@tiptap/extension-code-block-lowlight": "^3.22.1", "@tiptap/extension-image": "^3.22.1", "@tiptap/extension-link": "^3.22.1", "@tiptap/extension-mention": "^3.22.1", - "@tiptap/suggestion": "^3.22.1", "@tiptap/extension-placeholder": "^3.22.1", "@tiptap/extension-table": "^3.22.1", "@tiptap/extension-table-cell": "^3.22.1", @@ -33,6 +34,7 @@ "@tiptap/pm": "^3.22.1", "@tiptap/react": "^3.22.1", "@tiptap/starter-kit": "^3.22.1", + "@tiptap/suggestion": "^3.22.1", "@types/linkify-it": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx index b27ef15f..10fe30aa 100644 --- a/apps/web/test/helpers.tsx +++ b/apps/web/test/helpers.tsx @@ -83,8 +83,6 @@ export const mockAuthValue: Record = { leaveWorkspace: vi.fn(), deleteWorkspace: vi.fn(), refreshWorkspaces: vi.fn(), - refreshMembers: vi.fn(), - refreshAgents: vi.fn(), getMemberName: (userId: string) => { const m = mockMembers.find((m) => m.user_id === userId); return m?.name ?? "Unknown"; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f969e523..9110908d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -28,6 +28,9 @@ "paths": { "@/*": [ "./*" + ], + "@core/*": [ + "./core/*" ] }, "noEmit": true, diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index beb0e974..d24fbb00 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "."), + "@core": path.resolve(__dirname, "core"), }, }, }); 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. diff --git a/docs/plans/2026-04-08-board-dnd-rewrite.md b/docs/plans/2026-04-08-board-dnd-rewrite.md new file mode 100644 index 00000000..ba2fde1c --- /dev/null +++ b/docs/plans/2026-04-08-board-dnd-rewrite.md @@ -0,0 +1,511 @@ +# Board DnD Rewrite — dnd-kit Multi-Container Sortable + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Rewrite the Kanban board drag-and-drop to use dnd-kit's multi-container sortable pattern correctly — onDragOver for live cross-column movement, local state during drag, insertion indicators, and smooth animations. + +**Architecture:** Replace the current "TQ-cache-driven + pendingMove patch" with a "local-state-driven during drag, TQ sync on drop" model. During drag, a local `columns` state (Record) controls which IDs each SortableContext sees. onDragOver moves IDs between columns in real-time. onDragEnd computes final position and fires the mutation. Between drags, local state follows TQ data via useEffect. + +**Tech Stack:** @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2, TanStack Query, React useState + +--- + +## Current State (files to modify) + +| File | Current Role | Change | +|------|-------------|--------| +| `features/issues/components/board-view.tsx` | DndContext + onDragEnd only + pendingMove | **Rewrite**: local columns state, onDragOver, onDragEnd, improved DragOverlay | +| `features/issues/components/board-column.tsx` | Receives Issue[], sorts internally, useDroppable | **Rewrite**: receives sorted Issue[] from parent, no internal sorting, insertion indicator | +| `features/issues/components/board-card.tsx` | useSortable with defaults | **Modify**: custom animateLayoutChanges | +| `features/issues/components/issues-page.tsx` | handleMoveIssue callback | **Minor**: adjust callback signature | + +Files NOT changed: `mutations.ts`, `ws-updaters.ts`, `use-realtime-sync.ts`, `view-store.ts`, `sort.ts` + +--- + +## Task 1: Rewrite board-view.tsx — Local State + onDragOver + onDragEnd + +**Files:** +- Rewrite: `apps/web/features/issues/components/board-view.tsx` + +This is the core task. The entire DnD orchestration logic changes. + +### Data Model + +```typescript +// Local state: maps status → ordered array of issue IDs +// This is the ONLY source of truth for card positions during drag +type Columns = Record; +``` + +### Step 1: Replace pendingMove with local columns state + +Remove `pendingMove` + `displayIssues` + the clearing useEffect. Replace with: + +```typescript +// Build columns from TQ issues + view sort settings +function buildColumns( + issues: Issue[], + visibleStatuses: IssueStatus[], + sortBy: SortField, + sortDirection: SortDirection, +): Columns { + const cols: Columns = {} as Columns; + for (const status of visibleStatuses) { + const sorted = sortIssues( + issues.filter((i) => i.status === status), + sortBy, + sortDirection, + ); + cols[status] = sorted.map((i) => i.id); + } + return cols; +} +``` + +In the component: + +```typescript +const sortBy = useViewStore((s) => s.sortBy); +const sortDirection = useViewStore((s) => s.sortDirection); + +// Local columns state — follows TQ between drags, local during drag +const [columns, setColumns] = useState(() => + buildColumns(issues, visibleStatuses, sortBy, sortDirection) +); +const isDragging = useRef(false); + +// Sync from TQ when NOT dragging +useEffect(() => { + if (!isDragging.current) { + setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection)); + } +}, [issues, visibleStatuses, sortBy, sortDirection]); +``` + +`issueMap` for O(1) lookup (needed by BoardColumn to get Issue objects from IDs): + +```typescript +const issueMap = useMemo(() => { + const map = new Map(); + for (const issue of issues) map.set(issue.id, issue); + return map; +}, [issues]); +``` + +### Step 2: Implement findColumn helper + +```typescript +/** Find which column (status) contains a given ID (issue or column). */ +function findColumn(columns: Columns, id: string, visibleStatuses: IssueStatus[]): IssueStatus | null { + // Is it a column ID itself? + if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus; + // Search columns for the item + for (const [status, ids] of Object.entries(columns)) { + if (ids.includes(id)) return status as IssueStatus; + } + return null; +} +``` + +### Step 3: Implement onDragStart + +```typescript +const handleDragStart = useCallback((event: DragStartEvent) => { + isDragging.current = true; + const issue = issueMap.get(event.active.id as string) ?? null; + setActiveIssue(issue); +}, [issueMap]); +``` + +### Step 4: Implement onDragOver — the key missing piece + +This fires continuously during drag. When the pointer crosses into a different column or hovers over a different card, we move the dragged ID in local state. This makes SortableContext aware of the new item → cards shift to make room. + +```typescript +const handleDragOver = useCallback((event: DragOverEvent) => { + const { active, over } = event; + if (!over) return; + + const activeId = active.id as string; + const overId = over.id as string; + + const activeCol = findColumn(columns, activeId, visibleStatuses); + const overCol = findColumn(columns, overId, visibleStatuses); + if (!activeCol || !overCol || activeCol === overCol) return; + + // Cross-column move: remove from old column, insert into new column + setColumns((prev) => { + const oldIds = prev[activeCol]!.filter((id) => id !== activeId); + const newIds = [...prev[overCol]!]; + + // Insert position: if over a card, insert at that index; if over column, append + const overIndex = newIds.indexOf(overId); + const insertIndex = overIndex >= 0 ? overIndex : newIds.length; + newIds.splice(insertIndex, 0, activeId); + + return { ...prev, [activeCol]: oldIds, [overCol]: newIds }; + }); +}, [columns, visibleStatuses]); +``` + +### Step 5: Implement onDragEnd — persist to server + +```typescript +const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + isDragging.current = false; + setActiveIssue(null); + + if (!over) { + // Cancelled — reset to TQ state + setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection)); + return; + } + + const activeId = active.id as string; + const overId = over.id as string; + + const activeCol = findColumn(columns, activeId, visibleStatuses); + const overCol = findColumn(columns, overId, visibleStatuses); + if (!activeCol || !overCol) return; + + // Same column reorder + if (activeCol === overCol) { + const ids = columns[activeCol]!; + const oldIndex = ids.indexOf(activeId); + const newIndex = ids.indexOf(overId); + if (oldIndex !== newIndex) { + const reordered = arrayMove(ids, oldIndex, newIndex); + setColumns((prev) => ({ ...prev, [activeCol]: reordered })); + } + } + + // Compute final position from the local column order + const finalCol = findColumn(columns, activeId, visibleStatuses); + if (!finalCol) return; + + // After potential same-col reorder, re-read columns + // (for same-col we just did setColumns above, but it's async; + // however we can compute from the intended final order) + let finalIds: string[]; + if (activeCol === overCol) { + const ids = columns[activeCol]!; + const oldIndex = ids.indexOf(activeId); + const newIndex = ids.indexOf(overId); + finalIds = oldIndex !== newIndex ? arrayMove(ids, oldIndex, newIndex) : ids; + } else { + finalIds = columns[finalCol]!; + } + + const newPosition = computePosition(finalIds, activeId, issues); + const currentIssue = issueMap.get(activeId); + + // Skip if nothing changed + if (currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition) return; + + onMoveIssue(activeId, finalCol, newPosition); +}, [columns, issues, visibleStatuses, sortBy, sortDirection, issueMap, onMoveIssue]); +``` + +### Step 6: Update computePosition to work with ID arrays + +The current `computePosition` takes `Issue[]` and a target index. Rewrite to take `string[]` (IDs) + the active ID + the issue map: + +```typescript +/** Compute a float position for `activeId` based on its neighbors in `ids`. */ +function computePosition(ids: string[], activeId: string, allIssues: Issue[]): number { + const idx = ids.indexOf(activeId); + if (idx === -1) return 0; + + const getPos = (id: string) => allIssues.find((i) => i.id === id)?.position ?? 0; + + if (ids.length === 1) return 0; + if (idx === 0) return getPos(ids[1]!) - 1; + if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1; + return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2; +} +``` + +### Step 7: Update DragOverlay styling + +```typescript + + {activeIssue ? ( +
+ +
+ ) : null} +
+``` + +Key change: `dropAnimation={null}` prevents the overlay from animating back to origin on drop — the card is already in the right position via local state. + +### Step 8: Wire it all together + +Pass `columns` + `issueMap` to `BoardColumn` instead of `issues`: + +```tsx +{visibleStatuses.map((status) => ( + +))} +``` + +### Step 9: Run typecheck + +Run: `pnpm typecheck` +Expected: May have errors in board-column.tsx (prop changes) — that's Task 2. + +### Step 10: Commit + +```bash +git add apps/web/features/issues/components/board-view.tsx +git commit -m "refactor(board): rewrite DnD with local state + onDragOver for live cross-column sorting" +``` + +--- + +## Task 2: Rewrite board-column.tsx — Receive IDs + issueMap, Add Insertion Indicator + +**Files:** +- Rewrite: `apps/web/features/issues/components/board-column.tsx` + +### Step 1: Change props from `issues: Issue[]` to `issueIds: string[]` + `issueMap: Map` + +The column no longer does its own sorting — the parent provides IDs in the correct order. The column just resolves IDs to Issue objects and renders them. + +```typescript +export function BoardColumn({ + status, + issueIds, + issueMap, +}: { + status: IssueStatus; + issueIds: string[]; + issueMap: Map; +}) { + const cfg = STATUS_CONFIG[status]; + const { setNodeRef, isOver } = useDroppable({ id: status }); + const viewStoreApi = useViewStoreApi(); + + // Resolve IDs to Issue objects (IDs are already sorted by parent) + const resolvedIssues = useMemo( + () => issueIds.flatMap((id) => { + const issue = issueMap.get(id); + return issue ? [issue] : []; + }), + [issueIds, issueMap], + ); + + return ( +
+
+
+ + + {cfg.label} + + + {issueIds.length} + +
+ {/* Right: add + menu — keep as-is */} +
+ + + + + } + /> + + viewStoreApi.getState().hideStatus(status)}> + + Hide column + + + + + useModalStore.getState().open("create-issue", { status })} + > + + + } + /> + Add issue + +
+
+
+ + {resolvedIssues.map((issue) => ( + + ))} + + {issueIds.length === 0 && ( +

+ No issues +

+ )} +
+
+ ); +} +``` + +Key changes: +- No more `useViewStore` for sort — parent handles sorting +- No more internal `sortIssues` call +- Uses `issueIds` for SortableContext (already in correct order) +- Count shows `issueIds.length` instead of `issues.length` + +### Step 2: Run typecheck + +Run: `pnpm typecheck` +Expected: PASS (or errors in issues-page.tsx — Task 4) + +### Step 3: Commit + +```bash +git add apps/web/features/issues/components/board-column.tsx +git commit -m "refactor(board): BoardColumn receives sorted IDs from parent, no internal sorting" +``` + +--- + +## Task 3: Modify board-card.tsx — Custom animateLayoutChanges + +**Files:** +- Modify: `apps/web/features/issues/components/board-card.tsx` + +### Step 1: Add custom animateLayoutChanges + +When a card is dragged across containers, dnd-kit triggers a layout animation on the "entering" card. The default `defaultAnimateLayoutChanges` animates this, causing a jarring jump. We disable animation for the frame when `wasDragging` is true (the card just landed in a new container). + +```typescript +import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable"; +import type { AnimateLayoutChanges } from "@dnd-kit/sortable"; + +const animateLayoutChanges: AnimateLayoutChanges = (args) => { + const { isSorting, wasDragging } = args; + if (isSorting || wasDragging) return false; + return defaultAnimateLayoutChanges(args); +}; +``` + +Update useSortable call: + +```typescript +const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, +} = useSortable({ + id: issue.id, + data: { status: issue.status }, + animateLayoutChanges, +}); +``` + +### Step 2: Run typecheck + +Run: `pnpm typecheck` +Expected: PASS + +### Step 3: Commit + +```bash +git add apps/web/features/issues/components/board-card.tsx +git commit -m "refactor(board): custom animateLayoutChanges to prevent jarring cross-column animation" +``` + +--- + +## Task 4: Adjust issues-page.tsx — Minor Callback Cleanup + +**Files:** +- Modify: `apps/web/features/issues/components/issues-page.tsx` + +### Step 1: Update handleMoveIssue + +The callback shape stays the same (`issueId, newStatus, newPosition`), but the auto-switch-to-manual-sort logic should move into board-view or stay here. Keep it here for now since it's a view-level concern. + +No functional change needed — the `onMoveIssue` prop signature is unchanged. Just verify that `BoardView`'s new props are correct: + +```tsx + +``` + +`BoardView` still receives `issues` (filtered+scoped from TQ) and `onMoveIssue`. The internal state management changes are encapsulated. + +### Step 2: Run full typecheck + test + +Run: `pnpm typecheck && pnpm test` +Expected: PASS + +### Step 3: Commit + +```bash +git add apps/web/features/issues/components/issues-page.tsx +git commit -m "refactor(board): verify issues-page props match new BoardView interface" +``` + +--- + +## Task 5: Manual QA Checklist + +After all code changes, verify these scenarios in the browser: + +1. **Same-column reorder**: Drag a card up/down within one column → cards shift to make room during drag → drop → position persists after refresh +2. **Cross-column move**: Drag card from Todo to In Progress → card appears in target column DURING drag → target column cards shift → drop → status + position persist +3. **Drop on empty column**: Drag card to an empty column → card lands there +4. **Cancel drag**: Start dragging, press Escape → card returns to original position, no mutation fired +5. **Rapid sequential drags**: Drag card A, drop, immediately drag card B → no flicker or stale state +6. **WebSocket update during drag**: Have another user change an issue → board updates correctly after drag ends (not during) +7. **Sort mode switch**: Drag should auto-switch to "Manual" sort → verify after drag, sort dropdown shows "Manual" +8. **DragOverlay**: Dragged card should have visible shadow, slight rotation, slight scale up +9. **Hidden columns panel**: Still shows correct counts, "Show column" still works + +--- + +## Summary of Architecture Change + +``` +BEFORE (broken): + TQ cache → issues prop → displayIssues (with pendingMove patch) → BoardColumn sorts internally + onDragEnd → pendingMove + mutate → TQ updates → useEffect clears pendingMove + Problem: dual optimistic update, fire-and-forget cancelQueries race, no onDragOver + +AFTER (correct): + TQ cache → issues prop → buildColumns() → local columns state (when not dragging) + onDragStart → isDragging=true, freeze local state + onDragOver → move IDs between columns in local state → SortableContext sees new items → cards shift + onDragEnd → compute position from local order → mutate → isDragging=false → TQ catches up → local follows + Problem: none — single source of truth during drag (local), single source of truth between drags (TQ) +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27f5ad8c..6d84135b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,12 @@ importers: '@floating-ui/dom': specifier: ^1.7.6 version: 1.7.6 + '@tanstack/react-query': + specifier: ^5.96.2 + version: 5.96.2(react@19.2.3) + '@tanstack/react-query-devtools': + specifier: ^5.96.2 + version: 5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3) '@tiptap/extension-code-block-lowlight': specifier: ^3.22.1 version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0) @@ -1288,6 +1294,23 @@ packages: '@tailwindcss/postcss@4.2.2': resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + '@tanstack/query-core@5.96.2': + resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==} + + '@tanstack/query-devtools@5.96.2': + resolution: {integrity: sha512-vBTB1Qhbm3nHSbEUtQwks/EdcAtFfEapr1WyBW4w2ExYKuXVi3jIxUIHf5MlSltiHuL7zNyUuanqT/7sI2sb6g==} + + '@tanstack/react-query-devtools@5.96.2': + resolution: {integrity: sha512-nTFKLGuTOFvmFRvcyZ3ArWC/DnMNPoBh6h/2yD6rsf7TCTJCQt+oUWOp2uKPTIuEPtF/vN9Kw5tl5mD1Kbposw==} + peerDependencies: + '@tanstack/react-query': ^5.96.2 + react: ^18 || ^19 + + '@tanstack/react-query@5.96.2': + resolution: {integrity: sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -4917,6 +4940,21 @@ snapshots: postcss: 8.5.8 tailwindcss: 4.2.2 + '@tanstack/query-core@5.96.2': {} + + '@tanstack/query-devtools@5.96.2': {} + + '@tanstack/react-query-devtools@5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/query-devtools': 5.96.2 + '@tanstack/react-query': 5.96.2(react@19.2.3) + react: 19.2.3 + + '@tanstack/react-query@5.96.2(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.96.2 + react: 19.2.3 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0