This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
- 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
Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
server/— Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)apps/web/— Next.js frontend (App Router)apps/desktop/— Electron desktop app (electron-vite)packages/core/— Headless business logic (zero react-dom, all-platform reuse)packages/ui/— Atomic UI components (zero business logic)packages/views/— Shared business pages/components (zero next/* imports, zero react-router imports)packages/tsconfig/— Shared TypeScript configuration
Internal Packages pattern — all shared packages export raw .ts/.tsx files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
Dependency direction: views/ → core/ + ui/. Core and UI are independent of each other. No package imports from next/*, react-router-dom, or app-specific code.
Platform bridge: packages/core/platform/ provides CoreProvider — initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with <CoreProvider> and provides its own NavigationAdapter for routing.
pnpm catalog — pnpm-workspace.yaml defines catalog: for version pinning. All shared deps use catalog: references to guarantee a single version across all packages. When adding new shared deps (including test deps), add to catalog first.
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
- TanStack Query owns all server state. Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no
staleTimeworkarounds. - Zustand owns all client state. UI selections, filters, drafts, modal state, navigation history. Stores live in
packages/core/(never inpackages/views/) so both apps share them. - React Context is reserved for cross-cutting platform plumbing —
WorkspaceIdProvider,NavigationProvider. Don't reach for it for general state. - Auth and workspace stores are the only stores allowed to call
api.*directly, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
Hard rules — these are how the architecture stays coherent:
- Never duplicate server data into Zustand. If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
- Workspace-scoped queries must key on
wsId. This is what makes workspace switching automatic — the cache key changes, the right data appears, no manual invalidation needed. - Mutations are optimistic by default. Apply the change locally, send the request, roll back on failure, invalidate on settle. The user shouldn't wait for the server.
- WS events invalidate queries — they never write to stores directly. This keeps the cache as the single source of truth and avoids race conditions.
- Persist what's worth preserving across restarts (user preferences, drafts, tab layout). Don't persist ephemeral UI state (modal open/close, transient selections) or server data.
Common Zustand footguns to avoid:
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g.
s => ({ a: s.a, b: s.b })ors => s.items.map(...)) triggers infinite re-renders. Either select primitives separately or use shallow comparison. - Hooks that need workspace context should accept
wsIdas a parameter, not calluseWorkspaceId()internally — this lets them work outside theWorkspaceIdProvider(e.g. in a sidebar that renders before workspace is loaded).
# One-command dev (auto-setup + start everything)
make dev # Auto-creates env, installs deps, starts DB, migrates, launches app
# Explicit setup & run (if you prefer separate steps)
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 (all commands go through Turborepo)
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm dev:desktop # Electron dev (electron-vite, HMR)
pnpm build # Build all frontend apps
pnpm typecheck # TypeScript check (all packages + apps via turbo)
pnpm lint # ESLint
pnpm test # TS tests (Vitest, all packages + apps via turbo)
# Backend (Go)
make server # Run Go server only (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")
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 TS test (works for any package with a test script)
pnpm --filter @multica/views exec vitest run auth/login-page.test.tsx
pnpm --filter @multica/core exec vitest run runtimes/version.test.ts
pnpm --filter @multica/web exec vitest run app/\(auth\)/login/page.test.tsx
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Desktop build & package
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
# shadcn — config lives in packages/ui/components.json (Base UI variant, base-nova style)
pnpm ui:add badge # Adds component to packages/ui/components/ui/
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQLCI runs on Node 22 and Go 1.26.1 with a pgvector/pgvector:pg17 PostgreSQL service. See .github/workflows/ci.yml.
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.
make dev auto-detects worktrees and handles everything. For explicit control:
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
make start-worktree # Start using .env.worktree- TypeScript strict mode is enabled; keep types explicit.
- Go code follows standard Go conventions (gofmt, go vet).
- 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.
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (
/login,/inbox) or a/{noun}/{verb}pair (/workspaces/new). NEVER add hyphenated word-group root routes (/new-workspace,/create-team) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (workspaces) automatically protects the entire/workspaces/*subtree.
These are hard constraints. Violating them breaks the cross-platform architecture:
packages/core/— zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. All shared Zustand stores live here, even view-related ones (filters, view modes) — stores are pure state, not UI.packages/ui/— zero@multica/coreimports (pure UI, no business logic).packages/views/— zeronext/*imports, zeroreact-router-domimports, zero stores. UseNavigationAdapterfor all routing.apps/web/platform/— the only place for Next.js APIs (next/navigation).apps/desktop/src/renderer/src/platform/— the only place for react-router-dom navigation wiring.
If the same logic exists in both apps, it must be extracted to a shared package.
This applies to everything: components, hooks, guards, providers, utility functions. The decision process:
- Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
- Does it depend on
react-router-domornext/navigation? → Keep in app'splatform/layer. - Everything else → belongs in
packages/core/(headless logic) orpackages/views/(UI components).
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
When adding a new page or feature:
- New page component → add to
packages/views/<domain>/. Never import fromnext/*orreact-router-dom. - Wire it in both apps → add a route in
apps/web/app/(Next.js page file) AND in the desktop router. Exception: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they'reWindowOverlaystate. See Desktop-specific Rules → Route categories. - Navigation → use
useNavigation().push()or<AppLink>. Never use framework-specific link/router APIs in shared code. - Shared guards/providers → use
DashboardGuardfrompackages/views/layout/. Don't create separate guard logic per app. - Platform-specific UI → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (
extra,topSlot) on shared layout components to inject platform-specific UI. - New hooks that need workspace context → accept
wsIdas parameter instead of reading fromuseWorkspaceId()Context, so they work both inside and outsideWorkspaceIdProvider.
Both apps share the same CSS foundation from packages/ui/styles/.
- Design tokens → use semantic tokens (
bg-background,text-muted-foreground). Never use hardcoded Tailwind colors (text-red-500,bg-gray-100). - Shared styles →
packages/ui/styles/. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS. @sourcedirectives → both apps scan shared packages so Tailwind sees all class names.
These rules apply to apps/desktop/ only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
- Session routes — workspace-scoped pages (
/:slug/issues,/:slug/settings). Rendered by the per-tab memory router underWorkspaceRouteLayout. These are legitimate tab destinations. - Transition flows — pre-workspace / one-shot actions (create workspace, accept invite). NOT routes. They live as
WindowOverlaystate, dispatched when the navigation adapter seespush('/workspaces/new')orpush('/invite/<id>'). The shared view (NewWorkspacePage,InvitePage) is the content; the overlay wrapper supplies platform chrome. - Error / stale states — "workspace not available", tabs pointing at a revoked workspace. NOT pages.
WorkspaceRouteLayoutauto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keepsNoAccessPage(shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
Adding a new pre-workspace flow on desktop: register a new WindowOverlay type in stores/window-overlay-store.ts. Do NOT add it to routes.tsx. If a shared view needs the flow on both platforms, add the route on web (apps/web/app/(auth)/...) AND the overlay type on desktop — the shared view component is identical.
setCurrentWorkspace(slug, uuid) in @multica/core/platform is the single source of truth for "which workspace is active right now". Three consumers depend on it:
- API client's
X-Workspace-Slugheader. - Zustand per-workspace storage namespace.
- Chrome gating (
{slug && <AppSidebar />}on desktop, similar on web).
Normally set by WorkspaceRouteLayout when its route mounts. Critically: unmount does NOT clear it. Any code that leaves workspace context (leave workspace, delete workspace, force navigation to overlay) must call setCurrentWorkspace(null, null) explicitly — otherwise the realtime workspace:deleted handler races the mutation, chrome gating stays truthy while the workspace is gone from cache, and useWorkspaceId throws.
Leave / Delete workspace flows must follow this order:
- Read destination from cached workspace list (no extra fetch).
setCurrentWorkspace(null, null).navigation.push(destination)— switch to next workspace or open new-workspace overlay.- THEN
await mutation.mutateAsync(workspaceId).
Reversing step 4 with steps 1–3 (mutate first, navigate after) causes a three-way race between the mutation's onSettled invalidate, the explicit navigateAway, and the realtime handler's relocateAfterWorkspaceLoss — all refetching the same workspaces query concurrently. One gets cancelled, bubbles as CancelledError, and triggers window.location.assign → full renderer reload / white screen.
Tabs are grouped per workspace in stores/tab-store.ts. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace push(path) is detected by the navigation adapter (platform/navigation.tsx) and translated into switchWorkspace(slug, targetPath) — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through useNavigation() from shared code.
Every full-window desktop view (login, overlay, any page that covers the native title bar) needs a top drag strip so users can move the window. On macOS the traffic lights are hidden via useImmersiveMode in overlay-style contexts, so the drag strip also gives back that corner for pointer-drag.
Pattern: flex child at top, not absolute overlay.
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="h-12 shrink-0" style={{ WebkitAppRegion: "drag" }} />
<div className="flex-1 overflow-auto" style={{ WebkitAppRegion: "no-drag" }}>
{/* page content — interactive elements need their own "no-drag" */}
</div>
</div>Why flex, not absolute: the absolute-strip + z-index approach relies on stacking-context hit-testing, which isn't reliable for -webkit-app-region. A real flex row with no siblings at that pixel is unambiguous. Height matches MainTopBar (48px / h-12) for consistency.
Canonical examples: components/window-overlay.tsx, pages/login.tsx.
UX affordances (Back button, Log out button, welcome copy, invite card) belong in packages/views/ so web and desktop render identical content. Platform chrome (drag strip, useImmersiveMode, tab system interaction, traffic-light accommodation) lives in desktop-only code. Violating this split always produces platform divergence — if a button exists on desktop but not on web for the same flow, it's a signal the UX escaped into platform code.
- Prefer shadcn components over custom implementations. Install via
pnpm ui:add <component>from project root — adds topackages/ui/components/ui/. All components use Base UI primitives (@base-ui/react), not Radix. - Use shadcn design tokens for styling. Avoid hardcoded color values.
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
- Pay close attention to overflow (truncate long text, scrollable containers), alignment, and spacing consistency.
- If a component is identical between web and desktop, it belongs in a shared package. Do not copy-paste between apps.
Tests follow the code, not the app. This is the most important testing principle in this monorepo:
| What you're testing | Where the test lives | Why |
|---|---|---|
| Shared business logic (stores, queries, hooks) | packages/core/*.test.ts |
No DOM needed, pure logic |
| Shared UI components (pages, forms, modals) | packages/views/*.test.tsx |
jsdom, no framework mocks |
| Platform-specific wiring (cookies, redirects, searchParams) | apps/web/*.test.tsx or apps/desktop/ |
Needs framework-specific mocks |
| End-to-end user flows | e2e/*.spec.ts |
Real browser, real backend |
Never test shared component behavior in an app's test file. If a test requires mocking next/navigation or react-router-dom to test a component from @multica/views, the test is in the wrong place — move it to packages/views/ and mock @multica/core instead.
packages/core/— Vitest, Node environment (no DOM)packages/views/— Vitest, jsdom environment,@testing-library/reactapps/web/— Vitest, jsdom environment, framework-specific mockse2e/— Playwrightserver/— Go standardgo test
All test deps are in the pnpm catalog for unified versioning.
- Mock
@multica/corestores withvi.hoisted()+Object.assign(selectorFn, { getState })pattern (Zustand stores are both callable and have.getState()). - Mock
@multica/core/apifor API calls. - In
packages/views/tests: never mocknext/*orreact-router-dom— those don't exist here. - In
apps/web/tests: mock framework-specific APIs only for platform-specific behavior.
- Write failing test in the correct package first.
- Write implementation.
- Run
pnpm test(Turborepo discovers all packages). - Green → done.
Standard go test. Tests should create their own fixture data in a test database.
E2E tests should be self-contained. Use the TestApiClient fixture for data setup/teardown:
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
let api: TestApiClient;
test.beforeEach(async ({ page }) => {
api = await createTestApi();
await loginAsDefault(page);
});
test.afterEach(async () => {
await api.cleanup();
});
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue");
await page.goto(`/issues/${issue.id}`);
});- Use atomic commits grouped by logical intent.
- Conventional format:
feat(scope),fix(scope),refactor(scope),docs,test(scope),chore(scope).
make check # Runs all checks: typecheck, unit tests, Go tests, E2ERun verification only when the user explicitly asks for it.
For targeted checks when requested:
pnpm typecheck # TypeScript type errors only
pnpm test # TS unit tests only (Vitest, all packages)
make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)After writing or modifying code, always run the full verification pipeline:
make checkWorkflow:
- Write code to satisfy the requirement
- Run
make check - If any step fails, read the error output, fix the code, and re-run
- 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.
Prerequisite: A CLI release must accompany every Production deployment.
- Create a tag on the
mainbranch:git tag v0.x.x - Push the tag:
git push origin v0.x.x - GitHub Actions automatically triggers
release.yml: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
By default, bump the patch version each release (e.g. v0.1.12 → v0.1.13), unless the user specifies a specific version.
All queries filter by workspace_id. Membership checks gate access. X-Workspace-ID header routes requests to the correct workspace.
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).