* refactor(desktop): tabs are per-workspace, not cross-workspace Tabs are now grouped by workspace in the store; the TabBar shows only the active workspace's tabs, and switching workspace swaps the visible group. Before this change tabs were a flat list that spanned workspaces, which produced a confusing experience: working in acme with three tabs, then switching to butter and back, still showed whatever tabs you happened to open while you were in butter alongside your acme work. The bug had the same shape as the pre-workspace-overlay bug we fixed in #1237 — a concept ("workspace") was encoded in data (tab paths) but ignored by the UI that displayed it (TabBar). The fix is structural: make the data model match the concept. Key changes: - **Schema**: `{ activeWorkspaceSlug, byWorkspace: {slug: {tabs, activeTabId}} }`. The invariant "every tab belongs to a workspace group" is enforced at sanitize time and at migration time; there is no longer a root `/` sentinel. - **NavigationAdapter** detects cross-workspace pushes and delegates to `switchWorkspace(slug, path)` instead of navigating the active tab's router. All existing call sites in shared code (sidebar dropdown, settings post-delete redirect, invite-accept, cmd+k) keep calling `push(paths.workspace(x).issues())` unchanged. - **TabContent** renders only the active workspace's tabs under Activity. Cross-workspace state preservation is an explicit non-goal — switching workspaces should feel like switching. - **WorkspaceRouteLayout** auto-heal no longer navigates the tab router to `/`. Stale-slug cleanup is a store-level op (`validateWorkspaceSlugs`) that drops the whole stale group in one go. - **App.tsx** bootstrap seeds `activeWorkspaceSlug` when null and the user has workspaces; the new-workspace overlay opens/closes based on workspace count independently of any route. - **Persistence migration** (v1 → v2) groups old flat tabs by extracted slug, drops root / transition / reserved-slug tabs, and picks an active workspace from the old active tab's owning group. No data loss for existing users with workspace-scoped tabs. Web is unchanged — tabs are a desktop-only concept. `packages/views`, `packages/core`, `apps/web` are all untouched. `setCurrentWorkspace` in core remains the single source of truth for the API client's workspace header, driven by `WorkspaceRouteLayout` as before. Tests: 19 tab-store tests (sanitize, migration, switchWorkspace, validate, close-last-reseeds, reset). 38 desktop tests total pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: stable selectors + defensive guards on tab-store Addresses self-review findings on #1239. **C1 — perf cliff from unstable selector returns.** The previous `useActiveTab()` selector used `.find()` inside, so every router tick on the active tab (which replaces the Tab object via immutable spread in updateTab / updateTabHistory) forced every subscriber to re-render. Replaced with finer-grained selectors: - `useActiveTabIdentity()` — { slug, tabId } primitives (stable across unrelated updates). - `useActiveTabRouter()` — stable object reference for a tab's lifetime. - `useActiveTabHistory()` — { historyIndex, historyLength } numbers. `useTabHistory` and `DesktopNavigationProvider` now consume the primitive selectors, so back/forward buttons don't churn on every path change. A non-hook `getActiveTab(state)` helper covers the event-handler case. **I1 — `switchWorkspace` no-ops on empty slug.** Defensive guard in case a malformed path ever reaches the adapter's detector. **I2 — merge warns on path/slug mismatch.** Previously silent drop; now `console.warn` makes the condition visible during debugging. **Misc — TabRouterInner takes `tab` prop directly.** Passing the Tab object eliminates a redundant store read per rendered tab. Known follow-up (not this PR): `packages/core/realtime/use-realtime-sync.ts` still uses `window.location.assign` for workspace-deleted eviction — that's a full renderer reload on desktop, which post-refactor wastes the careful in-memory tab state we just set up. Fixing cleanly requires a navigation-callback injection pattern through CoreProvider, which is cross-cutting and deserves its own PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workspace): navigate away BEFORE leave/delete mutation to avoid CancelledError Symptom: deleting the current workspace logged "current workspace deleted, switching" from the realtime handler and surfaced an "Uncaught (in promise) CancelledError" from TanStack Query's refetchQueries batch. Root cause: a three-way race between the mutation's own invalidateQueries(workspaceKeys.list()), the settings page's navigateAwayFromCurrentWorkspace() fetchQuery, and the realtime workspace:deleted handler's relocateAfterWorkspaceLoss fetchQuery. All three refetched the same query concurrently; TanStack Query cancelled the in-flight loser(s), and the rejection bubbled out of invalidateQueries as an unhandled promise rejection. Fix: invert the order. Compute the destination from the current cached workspace list, navigate immediately, *then* fire the mutation. By the time the backend fires workspace:deleted, the active workspace is already something else — the realtime handler's "current === deleted" check fails and its relocate branch no-ops. Only one refetch happens (the mutation's onSettled), no race, no cancellation. navigateAwayFromCurrentWorkspace no longer needs async/fetchQuery since it reads from cache and returns before the mutation fires. Applies to both Leave and Delete flows. Both web and desktop benefit since the code is in packages/views. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(desktop): clear workspace singleton + flex drag strip + defer seeding Three issues that the last round of delete-workspace fixes missed. **1. `setCurrentWorkspace` singleton leaks after delete.** Navigating before the mutation (prior fix) changed the URL but nothing cleared the core platform's currentSlug/currentWsId singleton. Three downstream consumers still believed the deleted workspace was active: - `useRealtimeSync`'s `workspace:deleted` handler: its `getCurrentWsId() === deleted` check fired, triggering a parallel relocate that raced the mutation's invalidate and the settings page's navigate — CancelledError + `window.location.assign` (white screen reload). - Chrome gating: `{slug && <AppSidebar />}` stayed truthy, the sidebar mounted, and `useWorkspaceId` inside it threw because the workspace was gone from the list cache. - API client's `X-Workspace-Slug` header: stale on the next call. Fix: `navigateAwayFromCurrentWorkspace` now calls `setCurrentWorkspace(null, null)` before pushing. The next workspace's `WorkspaceRouteLayout` re-sets the singleton when it mounts; for the last-workspace case, null is the correct state (overlay has no workspace context). Same family as the previous logout bug: persist only writes to storage, reset on logout must also wipe in-memory state. Here the singleton is another in-memory bit that survives a URL change if we don't explicitly clear it. **2. "Cannot update a component while rendering" warning.** The per-workspace-tabs refactor kept the validate+seed call in render phase (matching the pre-refactor pattern). It worked before because `validateWorkspaceSlugs` is idempotent; the new `switchWorkspace` seed is not, and triggers a TabBar re-render during AppContent's render. Moved to `useLayoutEffect` — synchronously after render, before paint, no flicker. **3. Welcome-screen drag region didn't work on desktop.** The absolute-positioned `h-10 z-10` drag strip relied on z-index stacking to beat the content wrapper's no-drag for hit-testing, which wasn't reliable for `-webkit-app-region` on the overlay. Replaced with a flex child (`h-12 shrink-0` at top of the overlay's flex-col), so the drag region owns its own layout space — any pixel in the top 48 is unambiguously drag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(CLAUDE): desktop-specific rules — routing, singleton, drag, UX split Codifies the lessons from the recent desktop refactor series (#1237, #1238, #1239) so future work doesn't re-derive them from bugs. Covers: - **Route categories** (session / transition / error) — explains why `/workspaces/new` and `/invite/:id` are overlay state, not routes, on desktop; stale slugs auto-heal instead of rendering error pages. - **`setCurrentWorkspace` singleton hygiene** — unmount doesn't clear it; any code leaving workspace context must call `setCurrentWorkspace(null, null)` explicitly. - **Workspace destructive operations ordering** — navigate first, mutate after, to avoid the three-way refetch race that surfaces as CancelledError + full-page reload. - **Tab isolation** — tabs are grouped per workspace; cross-workspace push is intercepted by the navigation adapter and translated into switchWorkspace. - **Drag region pattern** — flex child at top, not absolute overlay; `-webkit-app-region` hit-testing is unreliable with z-index stacking. - **UX vs platform chrome split** — UX affordances (Back, Log out, welcome copy) in packages/views/; platform chrome (drag, immersive mode, tab system) in desktop-only code. Also patches the Cross-Platform Development Rules' rule #2 which previously said "add a route in both apps" unconditionally — added the exception for pre-workspace transition flows pointing at the new Desktop-specific Rules section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Context
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
Architecture
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
Key Architectural Decisions
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.
State Management
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).
Commands
# 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 PostgreSQL
CI Requirements
CI runs on Node 22 and Go 1.26.1 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.
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
Coding Rules
- 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.
Package Boundary Rules
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.
The No-Duplication Rule
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.
Cross-Platform Development Rules
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.
CSS Architecture
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.
Desktop-specific Rules
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.
Route categories
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.
Workspace identity singleton
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.
Workspace destructive operations
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.
Tab isolation
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.
Drag region (macOS window-move)
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 vs platform chrome
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.
UI/UX Rules
- 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.
Testing Rules
Where to write tests
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.
Test infrastructure
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.
Mocking conventions
- 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.
TDD workflow
- Write failing test in the correct package first.
- Write implementation.
- Run
pnpm test(Turborepo discovers all packages). - Green → done.
Go tests
Standard go test. Tests should create their own fixture data in a test database.
E2E tests
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}`);
});
Commit Rules
- Use atomic commits grouped by logical intent.
- Conventional format:
feat(scope),fix(scope),refactor(scope),docs,test(scope),chore(scope).
Minimum Pre-Push Checks
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:
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)
AI Agent Verification Loop
After writing or modifying code, always run the full verification pipeline:
make check
Workflow:
- 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.
CLI Release
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.
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).