diff --git a/AGENTS.md b/AGENTS.md index 8b224583d..81cd47b0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,273 +2,46 @@ This file provides guidance to AI agents when working with code in this repository. -## Project Context +> **Single source of truth:** This file is a concise pointer document. +> All authoritative architecture, coding rules, commands, and conventions +> live in **CLAUDE.md** at the project root. Read that file first. -Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens. +## Quick Reference -- Agents can be assigned issues, create issues, comment, and change status -- Supports local (daemon) and cloud agent runtimes -- Built for 2-10 person AI-native teams +### Architecture -## Architecture +Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages. -**Go backend + standalone Next.js frontend.** +- `server/` — Go backend (Chi router, sqlc, gorilla/websocket) +- `apps/web/` — Next.js frontend (App Router) +- `apps/desktop/` — Electron desktop app +- `packages/core/` — Headless business logic (Zustand stores, React Query hooks, API client) +- `packages/ui/` — Atomic UI components (shadcn/Base UI, zero business logic) +- `packages/views/` — Shared business pages/components +- `packages/tsconfig/` — Shared TypeScript config -- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time) -- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies -- `e2e/` — Playwright end-to-end tests -- `scripts/` and root `Makefile` — local setup and verification +### State Management (critical) -### Web App Structure (`apps/web/`) +- **React Query** owns all server state (issues, members, agents, inbox, workspace list) +- **Zustand** owns all client state (current workspace selection, view filters, drafts, modals) +- All Zustand stores live in `packages/core/` — never in `packages/views/` or app directories +- WS events invalidate React Query — never write directly to stores -The frontend uses a **feature-based architecture** with four layers: +### Package Boundaries (hard rules) -``` -apps/web/ -├── app/ # Routing layer (thin shells — import from features/) -├── features/ # Business logic, organized by domain -├── shared/ # Cross-feature utilities (api client, types, logger) -├── test/ # Shared test utilities and setup -├── public/ # Static assets -``` +- `packages/core/` — zero react-dom, zero localStorage, zero process.env +- `packages/ui/` — zero `@multica/core` imports +- `packages/views/` — zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing +- `apps/web/platform/` — only place for Next.js APIs -**`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: - -| 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/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/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`). -- **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)`. -- 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. - -### Import Aliases - -Use `@/` alias (maps to `apps/web/`): -```typescript -import { api } from "@/shared/api"; -import type { Issue } from "@/shared/types"; -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 `@/`. - -### Data Flow - -``` -Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL -Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService -``` - -### Backend Structure (`server/`) - -- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate` -- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`. -- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO. -- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found. -- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition. -- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels. -- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider. -- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting. -- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services. -- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error). -- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`. -- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model). - -### Multi-tenancy - -All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace. - -### Agent Assignees - -Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon). - -## Commands +### Commands ```bash -# One-click setup & run -make setup # First-time: ensure shared DB, create app DB, migrate -make start # Start backend + frontend together -make stop # Stop app processes for the current checkout -make db-down # Stop the shared PostgreSQL container - -# Frontend -pnpm install -pnpm dev:web # Next.js dev server (port 3000) -pnpm build # Build frontend +make dev # Auto-setup + start everything pnpm typecheck # TypeScript check -pnpm lint # ESLint via Next.js -pnpm test # TS tests (Vitest) - -# Backend (Go) -make dev # Run Go server (port 8080) -make daemon # Run local daemon -make build # Build server + CLI binaries to server/bin/ -make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config") +pnpm test # TS unit tests (Vitest) make test # Go tests -make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/ -make migrate-up # Run database migrations -make migrate-down # Rollback migrations - -# Run a single Go test -cd server && go test ./internal/handler/ -run TestName - -# Run a single TS test -pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts - -# Run a single E2E test (requires backend + frontend running) -pnpm exec playwright test e2e/tests/specific-test.spec.ts - -# Infrastructure -make db-up # Start shared PostgreSQL (pgvector/pg17 image) -make db-down # Stop shared PostgreSQL +make check # Full verification pipeline ``` -### CI Requirements - -CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`. - -### Worktree Support - -All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`. - -```bash -make worktree-env # Generate .env.worktree with unique DB/ports -make setup-worktree # Setup using .env.worktree -make start-worktree # Start using .env.worktree -``` - -## Coding Rules - -- TypeScript strict mode is enabled; keep types explicit. -- TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons. -- Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`. -- Go code follows standard Go conventions (gofmt, go vet). Use domain-oriented filenames like `issue.go` or `cmd_issue.go`. -- Do not hand-edit generated code in `server/pkg/db/generated/`. -- Keep comments in code **English only**. -- Prefer existing patterns/components over introducing parallel abstractions. -- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims. -- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior. -- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about. -- Avoid broad refactors unless required by the task. - -## UI/UX Rules - -- 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. -- 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. - -## Testing Rules - -- **TypeScript**: Vitest with Testing Library. Shared test setup lives in `apps/web/test/`. Mock external/third-party dependencies only. -- **Go**: Standard `go test`. Tests should create their own fixture data in a test database. -- End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running. -- Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows. - -## Commit & Pull Request Rules - -- Use atomic commits grouped by logical intent. -- Conventional format with scopes: - - `feat(web): ...`, `feat(cli): ...` - - `fix(web): ...`, `fix(cli): ...` - - `refactor(daemon): ...` - - `test(cli): ...` - - `docs: ...` - - `chore(scope): ...` -- Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes. -- Before opening a PR, run `make check` or the relevant frontend/backend subset. - -## Minimum Pre-Push Checks - -```bash -make check # Runs all checks: typecheck, unit tests, Go tests, E2E -``` - -Run verification only when the user explicitly asks for it. - -For targeted checks when requested: -```bash -pnpm typecheck # TypeScript type errors only -pnpm test # TS unit tests only (Vitest) -make test # Go tests only -pnpm exec playwright test # E2E only (requires backend + frontend running) -``` - -## AI Agent Verification Loop - -After writing or modifying code, always run the full verification pipeline: - -```bash -make check -``` - -This runs all checks in sequence: -1. TypeScript typecheck (`pnpm typecheck`) -2. TypeScript unit tests (`pnpm test`) -3. Go tests (`go test ./...`) -4. E2E tests (auto-starts backend + frontend if needed, runs Playwright) - -**Workflow:** -- Write code to satisfy the requirement -- Run `make check` -- If any step fails, read the error output, fix the code, and re-run `make check` -- Repeat until all checks pass -- Only then consider the task complete - -**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete. - -## E2E Test Patterns - -E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown: - -```typescript -import { loginAsDefault, createTestApi } from "./helpers"; -import type { TestApiClient } from "./fixtures"; - -let api: TestApiClient; - -test.beforeEach(async ({ page }) => { - api = await createTestApi(); // logged-in API client - await loginAsDefault(page); // browser session -}); - -test.afterEach(async () => { - await api.cleanup(); // delete any data created during the test -}); - -test("example", async ({ page }) => { - const issue = await api.createIssue("Test Issue"); // create via API - await page.goto(`/issues/${issue.id}`); // test via UI - // api.cleanup() in afterEach removes the issue -}); -``` +See CLAUDE.md for the complete command reference. diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx index b16111428..98e04182a 100644 --- a/apps/web/test/helpers.tsx +++ b/apps/web/test/helpers.tsx @@ -75,14 +75,9 @@ export const mockAuthValue: Record = { isLoading: false, login: vi.fn(), logout: vi.fn(), - workspaces: [mockWorkspace], switchWorkspace: vi.fn(), - createWorkspace: vi.fn(), updateWorkspace: vi.fn(), updateCurrentUser: vi.fn(), - leaveWorkspace: vi.fn(), - deleteWorkspace: vi.fn(), - refreshWorkspaces: vi.fn(), getMemberName: (userId: string) => { const m = mockMembers.find((m) => m.user_id === userId); return m?.name ?? "Unknown"; diff --git a/packages/core/platform/auth-initializer.tsx b/packages/core/platform/auth-initializer.tsx index 712c8a690..7c2c18ceb 100644 --- a/packages/core/platform/auth-initializer.tsx +++ b/packages/core/platform/auth-initializer.tsx @@ -1,9 +1,11 @@ "use client"; import { useEffect, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { getApi } from "../api"; import { useAuthStore } from "../auth"; import { useWorkspaceStore } from "../workspace"; +import { workspaceKeys } from "../workspace/queries"; import { createLogger } from "../logger"; import { defaultStorage } from "./storage"; import type { StorageAdapter } from "../types/storage"; @@ -21,6 +23,8 @@ export function AuthInitializer({ onLogout?: () => void; storage?: StorageAdapter; }) { + const qc = useQueryClient(); + useEffect(() => { const token = storage.getItem("multica_token"); if (!token) { @@ -37,6 +41,8 @@ export function AuthInitializer({ .then(([user, wsList]) => { onLogin?.(); useAuthStore.setState({ user, isLoading: false }); + // Seed React Query cache so components don't need a second fetch + qc.setQueryData(workspaceKeys.list(), wsList); useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId); }) .catch((err) => { diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index e25819039..68a026fd2 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -20,7 +20,7 @@ import { } from "../issues/ws-updaters"; import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inbox/ws-updaters"; import { inboxKeys } from "../inbox/queries"; -import { workspaceKeys } from "../workspace/queries"; +import { workspaceKeys, workspaceListOptions } from "../workspace/queries"; import type { MemberAddedPayload, WorkspaceDeletedPayload, @@ -251,7 +251,9 @@ export function useRealtimeSync( if (currentWs?.id === workspace_id) { logger.warn("current workspace deleted, switching"); onToast?.("This workspace was deleted", "info"); - workspaceStore.getState().refreshWorkspaces(); + qc.fetchQuery(workspaceListOptions()).then((wsList) => { + workspaceStore.getState().hydrateWorkspace(wsList); + }); } }); @@ -263,7 +265,9 @@ export function useRealtimeSync( if (wsId) clearWorkspaceStorage(defaultStorage, wsId); logger.warn("removed from workspace, switching"); onToast?.("You were removed from this workspace", "info"); - workspaceStore.getState().refreshWorkspaces(); + qc.fetchQuery(workspaceListOptions()).then((wsList) => { + workspaceStore.getState().hydrateWorkspace(wsList); + }); } }); @@ -271,7 +275,7 @@ export function useRealtimeSync( const { member, workspace_name } = p as MemberAddedPayload; const myUserId = authStore.getState().user?.id; if (member.user_id === myUserId) { - workspaceStore.getState().refreshWorkspaces(); + qc.invalidateQueries({ queryKey: workspaceKeys.list() }); onToast?.( `You were invited to ${workspace_name ?? "a workspace"}`, "info", diff --git a/packages/core/workspace/mutations.ts b/packages/core/workspace/mutations.ts index 14ca6bb88..512a01bf3 100644 --- a/packages/core/workspace/mutations.ts +++ b/packages/core/workspace/mutations.ts @@ -1,12 +1,17 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api"; -import { workspaceKeys } from "./queries"; +import { workspaceKeys, workspaceListOptions } from "./queries"; +import { useWorkspaceStore } from "./index"; export function useCreateWorkspace() { const qc = useQueryClient(); return useMutation({ mutationFn: (data: { name: string; slug: string; description?: string }) => api.createWorkspace(data), + onSuccess: (newWs) => { + // Switch to the newly created workspace immediately + useWorkspaceStore.getState().switchWorkspace(newWs); + }, onSettled: () => { qc.invalidateQueries({ queryKey: workspaceKeys.list() }); }, @@ -17,6 +22,14 @@ export function useLeaveWorkspace() { const qc = useQueryClient(); return useMutation({ mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId), + onSuccess: async (_, workspaceId) => { + const currentWsId = useWorkspaceStore.getState().workspace?.id; + if (currentWsId === workspaceId) { + // Left our current workspace — refetch and pick another + const wsList = await qc.fetchQuery(workspaceListOptions()); + useWorkspaceStore.getState().hydrateWorkspace(wsList); + } + }, onSettled: () => { qc.invalidateQueries({ queryKey: workspaceKeys.list() }); }, @@ -27,6 +40,14 @@ export function useDeleteWorkspace() { const qc = useQueryClient(); return useMutation({ mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId), + onSuccess: async (_, workspaceId) => { + const currentWsId = useWorkspaceStore.getState().workspace?.id; + if (currentWsId === workspaceId) { + // Deleted our current workspace — refetch and pick another + const wsList = await qc.fetchQuery(workspaceListOptions()); + useWorkspaceStore.getState().hydrateWorkspace(wsList); + } + }, onSettled: () => { qc.invalidateQueries({ queryKey: workspaceKeys.list() }); }, diff --git a/packages/core/workspace/store.ts b/packages/core/workspace/store.ts index 7dfb3781b..d2ba67122 100644 --- a/packages/core/workspace/store.ts +++ b/packages/core/workspace/store.ts @@ -13,24 +13,21 @@ interface WorkspaceStoreOptions { interface WorkspaceState { workspace: Workspace | null; - workspaces: Workspace[]; } interface WorkspaceActions { + /** + * Pick a workspace from a list and set it as current. + * The list itself is NOT stored here — it lives in React Query. + */ hydrateWorkspace: ( wsList: Workspace[], preferredWorkspaceId?: string | null, ) => Workspace | null; - switchWorkspace: (workspaceId: string) => void; - refreshWorkspaces: () => Promise; + /** Switch to a workspace. Caller provides the full object (from React Query). */ + switchWorkspace: (ws: Workspace) => void; + /** Update current workspace data in place (e.g. after rename). */ updateWorkspace: (ws: Workspace) => void; - createWorkspace: (data: { - name: string; - slug: string; - description?: string; - }) => Promise; - leaveWorkspace: (workspaceId: string) => Promise; - deleteWorkspace: (workspaceId: string) => Promise; clearWorkspace: () => void; } @@ -38,17 +35,13 @@ export type WorkspaceStore = WorkspaceState & WorkspaceActions; export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOptions) { const storage = options?.storage; - const onError = options?.onError; - return create((set, get) => ({ - // State + return create((set) => ({ + // Only the currently selected workspace (UI state). + // The workspace list is server state and lives in React Query. workspace: null, - workspaces: [], - // Actions hydrateWorkspace: (wsList, preferredWorkspaceId) => { - set({ workspaces: wsList }); - const nextWorkspace = (preferredWorkspaceId ? wsList.find((item) => item.id === preferredWorkspaceId) @@ -72,80 +65,29 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt set({ workspace: nextWorkspace }); logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id); - // 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: (workspaceId) => { - logger.info("switching to", workspaceId); - const { workspaces, hydrateWorkspace } = get(); - const ws = workspaces.find((item) => item.id === workspaceId); - if (!ws) return; - + switchWorkspace: (ws) => { + logger.info("switching to", ws.id); api.setWorkspaceId(ws.id); + setCurrentWorkspaceId(ws.id); + rehydrateAllWorkspaceStores(); storage?.setItem("multica_workspace_id", ws.id); - - // All data caches (issues, inbox, members, agents, skills, runtimes) - // are managed by TanStack Query, keyed by wsId — auto-refetch on switch. set({ workspace: ws }); - - hydrateWorkspace(workspaces, ws.id); - }, - - refreshWorkspaces: async () => { - const { workspace, hydrateWorkspace } = get(); - const storedWorkspaceId = storage?.getItem("multica_workspace_id") ?? null; - try { - const wsList = await api.listWorkspaces(); - hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId); - return wsList; - } catch (e) { - logger.error("failed to refresh workspaces", e); - onError?.("Failed to refresh workspaces"); - return get().workspaces; - } }, updateWorkspace: (ws) => { set((state) => ({ workspace: state.workspace?.id === ws.id ? ws : state.workspace, - workspaces: state.workspaces.map((item) => - item.id === ws.id ? ws : item, - ), })); }, - 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); - hydrateWorkspace(wsList, preferredWorkspaceId); - }, - - deleteWorkspace: async (workspaceId) => { - await api.deleteWorkspace(workspaceId); - const { workspace, hydrateWorkspace } = get(); - const wsList = await api.listWorkspaces(); - const preferredWorkspaceId = - workspace?.id === workspaceId ? null : (workspace?.id ?? null); - hydrateWorkspace(wsList, preferredWorkspaceId); - }, - clearWorkspace: () => { api.setWorkspaceId(null); setCurrentWorkspaceId(null); rehydrateAllWorkspaceStores(); - set({ workspace: null, workspaces: [] }); + set({ workspace: null }); }, })); } diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index 049b3315a..630a84661 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -59,6 +59,7 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; import { useAuthStore } from "@multica/core/auth"; import { useWorkspaceStore } from "@multica/core/workspace"; +import { workspaceListOptions } from "@multica/core/workspace/queries"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries"; import { api } from "@multica/core/api"; @@ -162,8 +163,8 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } const userId = useAuthStore((s) => s.user?.id); const authLogout = useAuthStore((s) => s.logout); const workspace = useWorkspaceStore((s) => s.workspace); - const workspaces = useWorkspaceStore((s) => s.workspaces); const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace); + const { data: workspaces = [] } = useQuery(workspaceListOptions()); const wsId = workspace?.id; const { data: inboxItems = [] } = useQuery({ @@ -278,7 +279,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } onClick={() => { if (ws.id !== workspace?.id) { push("/issues"); - switchWorkspace(ws.id); + switchWorkspace(ws); } }} > diff --git a/packages/views/modals/create-workspace.tsx b/packages/views/modals/create-workspace.tsx index f2e623a5e..d41a7564b 100644 --- a/packages/views/modals/create-workspace.tsx +++ b/packages/views/modals/create-workspace.tsx @@ -14,15 +14,15 @@ import { DialogDescription, } from "@multica/ui/components/ui/dialog"; import { Card, CardContent } from "@multica/ui/components/ui/card"; -import { useWorkspaceStore } from "@multica/core/workspace"; +import { useCreateWorkspace } from "@multica/core/workspace/mutations"; const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) { const router = useNavigation(); + const createWorkspace = useCreateWorkspace(); const [name, setName] = useState(""); const [slug, setSlug] = useState(""); - const [creating, setCreating] = useState(false); const slugError = slug.length > 0 && !SLUG_REGEX.test(slug) @@ -41,24 +41,20 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) { ); }; - const handleCreate = async () => { + const handleCreate = () => { if (!canSubmit) return; - setCreating(true); - try { - const { createWorkspace, switchWorkspace } = - useWorkspaceStore.getState(); - const ws = await createWorkspace({ - name: name.trim(), - slug: slug.trim(), - }); - onClose(); - router.push("/issues"); - await switchWorkspace(ws.id); - } catch { - toast.error("Failed to create workspace"); - } finally { - setCreating(false); - } + createWorkspace.mutate( + { name: name.trim(), slug: slug.trim() }, + { + onSuccess: () => { + onClose(); + router.push("/issues"); + }, + onError: () => { + toast.error("Failed to create workspace"); + }, + }, + ); }; return ( @@ -125,9 +121,9 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) { className="w-full" size="lg" onClick={handleCreate} - disabled={creating || !canSubmit} + disabled={createWorkspace.isPending || !canSubmit} > - {creating ? "Creating..." : "Create workspace"} + {createWorkspace.isPending ? "Creating..." : "Create workspace"} diff --git a/packages/views/settings/components/workspace-tab.tsx b/packages/views/settings/components/workspace-tab.tsx index fedb8d4ae..147b4ab48 100644 --- a/packages/views/settings/components/workspace-tab.tsx +++ b/packages/views/settings/components/workspace-tab.tsx @@ -21,6 +21,7 @@ import { toast } from "sonner"; import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@multica/core/auth"; import { useWorkspaceStore } from "@multica/core/workspace"; +import { useLeaveWorkspace, useDeleteWorkspace } from "@multica/core/workspace/mutations"; import { useWorkspaceId } from "@multica/core/hooks"; import { memberListOptions } from "@multica/core/workspace/queries"; import { api } from "@multica/core/api"; @@ -31,8 +32,8 @@ export function WorkspaceTab() { 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); + const leaveWorkspace = useLeaveWorkspace(); + const deleteWorkspace = useDeleteWorkspace(); const [name, setName] = useState(workspace?.name ?? ""); const [description, setDescription] = useState(workspace?.description ?? ""); @@ -83,7 +84,7 @@ export function WorkspaceTab() { onConfirm: async () => { setActionId("leave"); try { - await leaveWorkspace(workspace.id); + await leaveWorkspace.mutateAsync(workspace.id); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to leave workspace"); } finally { @@ -102,7 +103,7 @@ export function WorkspaceTab() { onConfirm: async () => { setActionId("delete-workspace"); try { - await deleteWorkspace(workspace.id); + await deleteWorkspace.mutateAsync(workspace.id); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to delete workspace"); } finally {