refactor: migrate workspace list from Zustand to React Query

- Remove workspaces[] from workspace store — list is server state, belongs in React Query
- Change switchWorkspace(id) → switchWorkspace(ws) — caller provides full object from Query
- Remove createWorkspace/leaveWorkspace/deleteWorkspace store actions (duplicated mutations)
- Remove refreshWorkspaces store action — replaced by qc.fetchQuery + hydrateWorkspace
- Enhance useLeaveWorkspace/useDeleteWorkspace mutations to re-select workspace when current is removed
- useCreateWorkspace mutation now switches to new workspace on success
- AuthInitializer seeds React Query cache on boot to avoid double fetch
- Realtime sync: replace refreshWorkspaces() calls with qc.fetchQuery + hydrateWorkspace
- Sidebar reads workspace list from useQuery(workspaceListOptions()) instead of Zustand
- create-workspace modal and workspace settings tab use mutations directly
- AGENTS.md: rewrite to match current monorepo architecture, pointing to CLAUDE.md

Fixes workspace rename not updating sidebar without page refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-13 13:38:02 +08:00
parent 68b101fe01
commit 43466a6402
9 changed files with 104 additions and 365 deletions

283
AGENTS.md
View File

@@ -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/<domain>/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.

View File

@@ -75,14 +75,9 @@ export const mockAuthValue: Record<string, any> = {
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";

View File

@@ -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) => {

View File

@@ -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",

View File

@@ -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() });
},

View File

@@ -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<Workspace[]>;
/** 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<Workspace>;
leaveWorkspace: (workspaceId: string) => Promise<void>;
deleteWorkspace: (workspaceId: string) => Promise<void>;
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<WorkspaceStore>((set, get) => ({
// State
return create<WorkspaceStore>((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 });
},
}));
}

View File

@@ -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);
}
}}
>

View File

@@ -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"}
</Button>
</div>
</DialogContent>

View File

@@ -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 {