diff --git a/.env.example b/.env.example
index 3c410d41..312ec87a 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,4 @@
# Database
-COMPOSE_PROJECT_NAME=super_multica
POSTGRES_DB=multica
POSTGRES_USER=multica
POSTGRES_PASSWORD=multica
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 033c78a1..afe63e0e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,6 +43,22 @@ jobs:
backend:
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: pgvector/pgvector:pg17
+ env:
+ POSTGRES_DB: multica
+ POSTGRES_USER: multica
+ POSTGRES_PASSWORD: multica
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U multica -d multica"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 20
+ env:
+ DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -56,5 +72,8 @@ jobs:
- name: Build
run: cd server && go build ./...
+ - name: Run migrations
+ run: cd server && go run ./cmd/migrate up
+
- name: Test
run: cd server && go test ./...
diff --git a/CLAUDE.md b/CLAUDE.md
index 840b5f70..312d6832 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,6 +18,56 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
- `apps/web/` — Next.js 16 frontend (App Router)
- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils)
+### Web App Structure (`apps/web/`)
+
+The frontend uses a **feature-based architecture** with three layers:
+
+```
+apps/web/
+├── app/ # Routing layer (thin shells — import from features/)
+├── features/ # Business logic, organized by domain
+├── shared/ # Cross-feature utilities (api client)
+```
+
+**`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 components and config | Icons, pickers, status/priority config |
+| `features/realtime/` | WebSocket connection | `WSProvider`, `useWSEvent` |
+
+**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton).
+
+### State Management
+
+- **Zustand** for global client state (`features/auth/store.ts`, `features/workspace/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 { useAuthStore } from "@/features/auth";
+import { useWorkspaceStore } from "@/features/workspace";
+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
```
@@ -63,10 +113,10 @@ Assignees are polymorphic — can be a member or an agent. `assignee_type` + `as
```bash
# One-click setup & run
-make setup # First-time: install deps, start DB, migrate
-make seed # Optional: load example data
+make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
-make stop # Stop everything
+make stop # Stop app processes for the current checkout
+make db-down # Stop the shared PostgreSQL container
# Frontend
pnpm install
@@ -93,8 +143,8 @@ pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Infrastructure
-docker compose up -d # Start PostgreSQL
-docker compose down # Stop PostgreSQL
+make db-up # Start shared PostgreSQL
+make db-down # Stop shared PostgreSQL
```
### Worktree Support
@@ -120,14 +170,18 @@ make start-worktree # Start using .env.worktree
## UI/UX Rules
- Prefer `packages/ui` shadcn components over custom implementations.
-- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
+- **shadcn official components** → `packages/ui/src/components/ui/` — keep this directory clean; install missing components via `npx shadcn add`, do not mix in business code.
+- **Shared business components & utils** → `packages/ui/src/components/common/` — reusable project-level UI components (e.g. ActorAvatar) and shared utilities live here.
+- **Feature-specific components** → `features//components/` — issue icons, pickers, and other domain-bound UI live inside their feature module, not in `packages/ui`.
+- 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. Mock external/third-party dependencies only.
-- **Go**: Standard `go test`. Use testcontainers or test database for DB tests.
+- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
## Commit Rules
@@ -146,7 +200,9 @@ make start-worktree # Start using .env.worktree
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
```
-For individual checks during development:
+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)
diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md
new file mode 100644
index 00000000..b93af57b
--- /dev/null
+++ b/LOCAL_DEVELOPMENT.md
@@ -0,0 +1,471 @@
+# Local Development Guide
+
+This guide documents the intended local development workflow for Multica.
+
+It covers:
+
+- first-time setup
+- day-to-day development in the main checkout
+- isolated worktree development
+- the shared PostgreSQL model
+- testing and verification
+- troubleshooting and destructive reset options
+
+## Development Model
+
+Local development uses one shared PostgreSQL container and one database per checkout.
+
+- the main checkout usually uses `.env` and `POSTGRES_DB=multica`
+- each Git worktree uses its own `.env.worktree`
+- every checkout connects to the same PostgreSQL host: `localhost:5432`
+- isolation happens at the database level, not by starting a separate Docker Compose project
+- backend and frontend ports are still unique per worktree
+
+This keeps Docker simple while still isolating schema and data.
+
+## Prerequisites
+
+- Node.js `v20+`
+- `pnpm` `v10.28+`
+- Go `v1.26+`
+- Docker
+
+## Important Rules
+
+- The main checkout should use `.env`.
+- A worktree should use `.env.worktree`.
+- Do not copy `.env` into a worktree directory.
+
+Why:
+
+- the current command flow prefers `.env` over `.env.worktree`
+- if a worktree contains `.env`, it can accidentally point back to the main database
+
+## Environment Files
+
+### Main Checkout
+
+Create `.env` once:
+
+```bash
+cp .env.example .env
+```
+
+By default, `.env` points to:
+
+```bash
+POSTGRES_DB=multica
+POSTGRES_PORT=5432
+DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
+PORT=8080
+FRONTEND_PORT=3000
+```
+
+### Worktree
+
+Generate `.env.worktree` from inside the worktree:
+
+```bash
+make worktree-env
+```
+
+That generates values like:
+
+```bash
+POSTGRES_DB=multica_super_multica_702
+POSTGRES_PORT=5432
+PORT=18782
+FRONTEND_PORT=13702
+DATABASE_URL=postgres://multica:multica@localhost:5432/multica_super_multica_702?sslmode=disable
+```
+
+Notes:
+
+- `POSTGRES_DB` is unique per worktree
+- `POSTGRES_PORT` stays fixed at `5432`
+- backend and frontend ports are derived from the worktree path hash
+- `make worktree-env` refuses to overwrite an existing `.env.worktree`
+
+To regenerate a worktree env file:
+
+```bash
+FORCE=1 make worktree-env
+```
+
+## First-Time Setup
+
+### Main Checkout
+
+From the main checkout:
+
+```bash
+cp .env.example .env
+make setup-main
+```
+
+What `make setup-main` does:
+
+- installs JavaScript dependencies with `pnpm install`
+- ensures the shared PostgreSQL container is running
+- creates the application database if it does not exist
+- runs all migrations against that database
+
+Start the app:
+
+```bash
+make start-main
+```
+
+Stop the app processes:
+
+```bash
+make stop-main
+```
+
+This does not stop PostgreSQL.
+
+### Worktree
+
+From the worktree directory:
+
+```bash
+make worktree-env
+make setup-worktree
+```
+
+What `make setup-worktree` does:
+
+- uses `.env.worktree`
+- ensures the shared PostgreSQL container is running
+- creates the worktree database if it does not exist
+- runs migrations against the worktree database
+
+Start the worktree app:
+
+```bash
+make start-worktree
+```
+
+Stop the worktree app processes:
+
+```bash
+make stop-worktree
+```
+
+## Recommended Daily Workflow
+
+### Main Checkout
+
+Use the main checkout when you want a stable local environment for `main`.
+
+```bash
+make start-main
+make stop-main
+make check-main
+```
+
+### Feature Worktree
+
+Use a worktree when you want isolated data and separate app ports.
+
+```bash
+git worktree add ../super-multica-feature -b feat/my-change main
+cd ../super-multica-feature
+make worktree-env
+make setup-worktree
+make start-worktree
+```
+
+After that, day-to-day commands are:
+
+```bash
+make start-worktree
+make stop-worktree
+make check-worktree
+```
+
+## Running Main and Worktree at the Same Time
+
+This is a first-class workflow.
+
+Example:
+
+- main checkout
+ - database: `multica`
+ - backend: `8080`
+ - frontend: `3000`
+- worktree checkout
+ - database: `multica_super_multica_702`
+ - backend: generated worktree port such as `18782`
+ - frontend: generated worktree port such as `13702`
+
+Both checkouts use:
+
+- the same PostgreSQL container
+- the same PostgreSQL port: `5432`
+
+But they do not share application data, because each uses a different database.
+
+## Command Reference
+
+### Shared Infrastructure
+
+Start the shared PostgreSQL container:
+
+```bash
+make db-up
+```
+
+Stop the shared PostgreSQL container:
+
+```bash
+make db-down
+```
+
+Important:
+
+- `make db-down` stops the container but keeps the Docker volume
+- your local databases are preserved
+
+### App Lifecycle
+
+Main checkout:
+
+```bash
+make setup-main
+make start-main
+make stop-main
+make check-main
+```
+
+Worktree:
+
+```bash
+make worktree-env
+make setup-worktree
+make start-worktree
+make stop-worktree
+make check-worktree
+```
+
+Generic targets for the current checkout:
+
+```bash
+make setup
+make start
+make stop
+make check
+make dev
+make test
+make migrate-up
+make migrate-down
+```
+
+These generic targets require a valid env file in the current directory.
+
+## How Database Creation Works
+
+Database creation is automatic.
+
+The following commands all ensure the target database exists before they continue:
+
+- `make setup`
+- `make start`
+- `make dev`
+- `make test`
+- `make migrate-up`
+- `make migrate-down`
+- `make check`
+
+That logic lives in `scripts/ensure-postgres.sh`.
+
+## Testing
+
+Run all local checks:
+
+```bash
+make check-main
+```
+
+Or from a worktree:
+
+```bash
+make check-worktree
+```
+
+This runs:
+
+1. TypeScript typecheck
+2. TypeScript unit tests
+3. Go tests
+4. Playwright E2E tests
+
+Notes:
+
+- Go tests create their own fixture data
+- E2E tests create their own workspace and issue fixtures
+- the check flow starts backend/frontend only if they are not already running
+
+## Local Codex Daemon
+
+Run the local daemon:
+
+```bash
+make daemon
+```
+
+Normal flow:
+
+1. start the daemon
+2. open the pairing link it prints
+3. choose the workspace in the browser
+4. let the daemon register its local runtime
+
+Debug shortcut:
+
+- you can set `MULTICA_WORKSPACE_ID` in your env file
+- this skips normal pairing
+- treat it as a local shortcut, not the default workflow
+
+## Troubleshooting
+
+### Missing Env File
+
+If you see:
+
+```text
+Missing env file: .env
+```
+
+or:
+
+```text
+Missing env file: .env.worktree
+```
+
+then create the expected env file first.
+
+Main checkout:
+
+```bash
+cp .env.example .env
+```
+
+Worktree:
+
+```bash
+make worktree-env
+```
+
+### Check Which Database a Checkout Uses
+
+Inspect the env file:
+
+```bash
+cat .env
+cat .env.worktree
+```
+
+Look for:
+
+- `POSTGRES_DB`
+- `DATABASE_URL`
+- `PORT`
+- `FRONTEND_PORT`
+
+### List All Local Databases in Shared PostgreSQL
+
+```bash
+docker compose exec -T postgres psql -U multica -d postgres -At -c "select datname from pg_database order by datname;"
+```
+
+### Worktree Is Accidentally Using the Main Database
+
+Check whether the worktree contains `.env`.
+
+It should not.
+
+The safe worktree setup is:
+
+```bash
+make worktree-env
+make setup-worktree
+make start-worktree
+```
+
+### App Stops but PostgreSQL Keeps Running
+
+That is expected.
+
+- `make stop`
+- `make stop-main`
+- `make stop-worktree`
+
+only stop backend/frontend processes.
+
+To stop the shared PostgreSQL container:
+
+```bash
+make db-down
+```
+
+## Destructive Reset
+
+If you want to stop PostgreSQL and keep your local databases:
+
+```bash
+make db-down
+```
+
+If you want to wipe all local PostgreSQL data for this repo:
+
+```bash
+docker compose down -v
+```
+
+Warning:
+
+- this deletes the shared Docker volume
+- this deletes the main database and every worktree database in that volume
+- after that you must run `make setup-main` or `make setup-worktree` again
+
+## Typical Flows
+
+### Stable Main Environment
+
+```bash
+cp .env.example .env
+make setup-main
+make start-main
+```
+
+### Feature Worktree
+
+```bash
+git worktree add ../super-multica-feature -b feat/my-change main
+cd ../super-multica-feature
+make worktree-env
+make setup-worktree
+make start-worktree
+```
+
+### Return to a Previously Configured Worktree
+
+```bash
+cd ../super-multica-feature
+make start-worktree
+```
+
+### Validate Before Pushing
+
+Main checkout:
+
+```bash
+make check-main
+```
+
+Worktree:
+
+```bash
+make check-worktree
+```
diff --git a/Makefile b/Makefile
index 929ba7c7..07d3213b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: dev daemon cli build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree
+.PHONY: dev daemon cli build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -20,47 +20,40 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
-COMPOSE_PROJECT_NAME ?= super_multica
export
-COMPOSE := docker compose --env-file $(ENV_FILE)
+COMPOSE := docker compose
+
+define REQUIRE_ENV
+ @if [ ! -f "$(ENV_FILE)" ]; then \
+ echo "Missing env file: $(ENV_FILE)"; \
+ echo "Create .env from .env.example, or run 'make worktree-env' and use .env.worktree."; \
+ exit 1; \
+ fi
+endef
# ---------- One-click commands ----------
# First-time setup: install deps, start DB, run migrations
setup:
+ $(REQUIRE_ENV)
@echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
pnpm install
- @echo "==> Starting PostgreSQL..."
- @if pg_isready -h localhost -p $(POSTGRES_PORT) -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; then \
- echo " PostgreSQL already running, skipping docker compose up."; \
- else \
- $(COMPOSE) up -d; \
- echo "==> Waiting for PostgreSQL to be ready..."; \
- until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \
- sleep 1; \
- done; \
- fi
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "==> Running migrations..."
cd server && go run ./cmd/migrate up
@echo ""
- @echo "✓ Setup complete! Run 'make seed' if you want example data, then 'make start' to launch the app."
+ @echo "✓ Setup complete! Run 'make start' to launch the app."
# Start all services (backend + frontend)
start:
+ $(REQUIRE_ENV)
@echo "Using env file: $(ENV_FILE)"
@echo "Backend: http://localhost:$(PORT)"
@echo "Frontend: http://localhost:$(FRONTEND_PORT)"
- @if pg_isready -h localhost -p $(POSTGRES_PORT) -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; then \
- echo "PostgreSQL already running, skipping docker compose up."; \
- else \
- $(COMPOSE) up -d; \
- until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \
- sleep 1; \
- done; \
- fi
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "Starting backend and frontend..."
@trap 'kill 0' EXIT; \
(cd server && go run ./cmd/server) & \
@@ -69,15 +62,22 @@ start:
# Stop all services
stop:
+ $(REQUIRE_ENV)
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
- $(COMPOSE) down
- @echo "✓ All services stopped."
+ @echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
# Full verification: typecheck + unit tests + Go tests + E2E
check:
- @bash scripts/check.sh
+ $(REQUIRE_ENV)
+ @ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
+
+db-up:
+ @$(COMPOSE) up -d postgres
+
+db-down:
+ @$(COMPOSE) down
worktree-env:
@bash scripts/init-worktree-env.sh .env.worktree
@@ -110,6 +110,8 @@ check-worktree:
# Go server
dev:
+ $(REQUIRE_ENV)
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
daemon:
@@ -126,21 +128,24 @@ build:
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica-cli ./cmd/multica
test:
+ $(REQUIRE_ENV)
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go test ./...
# Database
migrate-up:
+ $(REQUIRE_ENV)
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
migrate-down:
+ $(REQUIRE_ENV)
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate down
sqlc:
cd server && sqlc generate
-seed:
- cd server && go run ./cmd/seed
-
# Cleanup
clean:
rm -rf server/bin server/tmp
diff --git a/README.md b/README.md
index e821dbf8..eec73dcf 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
AI-native task management platform — like Linear, but with AI agents as first-class citizens.
+For the full local development workflow, see [Local Development Guide](LOCAL_DEVELOPMENT.md).
+
## Prerequisites
- [Node.js](https://nodejs.org/) (v20+)
@@ -18,19 +20,16 @@ pnpm install
# 2. Copy environment variables for the shared main environment
cp .env.example .env
-# 3. One-time setup: start DB and run migrations
+# 3. One-time setup: ensure shared PostgreSQL, create the app DB, run migrations
make setup
-# 4. Optional: load example data
-make seed
-
-# 5. Start backend + frontend
+# 4. Start backend + frontend
make start
```
Open your configured `FRONTEND_ORIGIN` in the browser. By default that is [http://localhost:3000](http://localhost:3000).
-Default behavior now prefers the shared main environment in `.env`. If you want an isolated environment for a Git worktree, generate `.env.worktree` and use the explicit worktree targets:
+Main checkout uses `.env`. A Git worktree should generate its own `.env.worktree` and use the explicit worktree targets:
```bash
make worktree-env
@@ -38,13 +37,19 @@ make setup-worktree
make start-worktree
```
-This lets you keep `.env` connected to your main database while using `.env.worktree` only for isolated feature testing.
+Every checkout shares the same PostgreSQL container on `localhost:5432`. Isolation now happens at the database level:
+
+- `.env` typically uses `POSTGRES_DB=multica`
+- each `.env.worktree` gets its own `POSTGRES_DB`, such as `multica_super_multica_702`
+- backend/frontend ports still stay unique per worktree
+
+That keeps one Docker container and one volume, while still isolating schema and data per worktree.
## Project Structure
```
├── server/ # Go backend (Chi + sqlc + gorilla/websocket)
-│ ├── cmd/ # server, daemon, migrate, seed
+│ ├── cmd/ # server, daemon, migrate
│ ├── internal/ # Core business logic
│ ├── migrations/ # SQL migrations
│ └── sqlc.yaml # sqlc config
@@ -87,11 +92,10 @@ This lets you keep `.env` connected to your main database while using `.env.work
| Command | Description |
|---------|-------------|
-| `docker compose up -d` | Start PostgreSQL |
-| `docker compose down` | Stop PostgreSQL |
-| `make migrate-up` | Run database migrations |
-| `make migrate-down` | Rollback database migrations |
-| `make seed` | Seed example data |
+| `make db-up` | Start the shared PostgreSQL container |
+| `make db-down` | Stop the shared PostgreSQL container |
+| `make migrate-up` | Ensure the current DB exists, then run migrations |
+| `make migrate-down` | Rollback database migrations for the current DB |
| `make worktree-env` | Generate an isolated `.env.worktree` for the current worktree |
| `make setup-main` / `make start-main` | Force use of the shared main `.env` |
| `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` |
@@ -101,8 +105,8 @@ This lets you keep `.env` connected to your main database while using `.env.work
See [`.env.example`](.env.example) for all available variables:
- `DATABASE_URL` — PostgreSQL connection string
-- `COMPOSE_PROJECT_NAME` — Docker Compose project name
-- `POSTGRES_DB` / `POSTGRES_PORT` — Per-worktree PostgreSQL database and host port
+- `POSTGRES_DB` — Database name for the current checkout or worktree
+- `POSTGRES_PORT` — Shared PostgreSQL host port (fixed to `5432`)
- `PORT` — Backend server port (default: 8080)
- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin
- `JWT_SECRET` — JWT signing secret
@@ -130,3 +134,9 @@ The local daemon currently supports one local runtime type: `codex`.
8. The daemon claims the task, runs `codex exec`, and reports the final comment back to the issue.
For local development you can still set `MULTICA_WORKSPACE_ID` directly to skip pairing, but that should be treated as a debug shortcut rather than the normal flow.
+
+## Local Development Notes
+
+- `make setup`, `make start`, `make dev`, and `make test` now require an env file. They fail fast if `.env` or `.env.worktree` is missing.
+- `make stop` only stops the backend/frontend processes for the current checkout. It does not stop the shared PostgreSQL container.
+- Use `make db-down` only when you explicitly want to shut down the shared local PostgreSQL instance for every checkout.
diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx
index d9d97164..e69555d4 100644
--- a/apps/web/app/(auth)/login/page.test.tsx
+++ b/apps/web/app/(auth)/login/page.test.tsx
@@ -9,27 +9,30 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
-// Mock auth-context
+// Mock auth store
const mockLogin = vi.fn();
-const mockAuthValue = {
- user: null,
- workspace: null,
- members: [],
- agents: [],
- isLoading: false,
- login: mockLogin,
- logout: vi.fn(),
- refreshMembers: vi.fn(),
- refreshAgents: vi.fn(),
- getMemberName: () => "Unknown",
- getAgentName: () => "Unknown Agent",
- getActorName: () => "System",
- getActorInitials: () => "XX",
-};
+vi.mock("@/features/auth", () => ({
+ useAuthStore: (selector: (s: any) => any) =>
+ selector({
+ login: mockLogin,
+ isLoading: false,
+ }),
+}));
-vi.mock("../../../lib/auth-context", () => ({
- useAuth: () => mockAuthValue,
- AuthProvider: ({ children }: { children: React.ReactNode }) => children,
+// Mock workspace store
+const mockHydrateWorkspace = vi.fn();
+vi.mock("@/features/workspace", () => ({
+ useWorkspaceStore: (selector: (s: any) => any) =>
+ selector({
+ hydrateWorkspace: mockHydrateWorkspace,
+ }),
+}));
+
+// Mock api
+vi.mock("@/shared/api", () => ({
+ api: {
+ listWorkspaces: vi.fn().mockResolvedValue([]),
+ },
}));
import LoginPage from "./page";
@@ -44,55 +47,54 @@ describe("LoginPage", () => {
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
- expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
- expect(screen.getByPlaceholderText("Name")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Sign in" })).toBeInTheDocument();
+ expect(screen.getByLabelText("Email")).toBeInTheDocument();
+ expect(screen.getByLabelText("Name")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
});
it("does not call login when email is empty", async () => {
const user = userEvent.setup();
render();
- // The email input has required attribute, so browser validation blocks submit
- // Verify login was never called
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(mockLogin).not.toHaveBeenCalled();
});
it("calls login with correct args on submit", async () => {
- mockLogin.mockResolvedValueOnce(undefined);
+ mockLogin.mockResolvedValueOnce({ id: "u1", name: "Test User" });
+ mockHydrateWorkspace.mockResolvedValueOnce(null);
const user = userEvent.setup();
render();
- await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
- await user.type(screen.getByPlaceholderText("Name"), "Test User");
+ await user.type(screen.getByLabelText("Email"), "test@multica.ai");
+ await user.type(screen.getByLabelText("Name"), "Test User");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
- expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User", undefined);
+ expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User");
});
});
it("calls login with email only when name is empty", async () => {
- mockLogin.mockResolvedValueOnce(undefined);
+ mockLogin.mockResolvedValueOnce({ id: "u1", name: "" });
+ mockHydrateWorkspace.mockResolvedValueOnce(null);
const user = userEvent.setup();
render();
- await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
+ await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
- expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined, undefined);
+ expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined);
});
});
it("shows 'Signing in...' while submitting", async () => {
- // Make login hang
mockLogin.mockReturnValueOnce(new Promise(() => {}));
const user = userEvent.setup();
render();
- await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
+ await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
@@ -105,7 +107,7 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render();
- await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
+ await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx
index 3d76dd87..a1d89c16 100644
--- a/apps/web/app/(auth)/login/page.tsx
+++ b/apps/web/app/(auth)/login/page.tsx
@@ -1,11 +1,27 @@
"use client";
import { Suspense, useState } from "react";
-import { useSearchParams } from "next/navigation";
-import { useAuth } from "../../../lib/auth-context";
+import { useSearchParams, useRouter } from "next/navigation";
+import { useAuthStore } from "@/features/auth";
+import { useWorkspaceStore } from "@/features/workspace";
+import { api } from "@/shared/api";
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+} from "@multica/ui/components/ui/card";
+import { Input } from "@multica/ui/components/ui/input";
+import { Button } from "@multica/ui/components/ui/button";
+import { Label } from "@multica/ui/components/ui/label";
function LoginPageContent() {
- const { login, isLoading } = useAuth();
+ const router = useRouter();
+ const login = useAuthStore((s) => s.login);
+ const isLoading = useAuthStore((s) => s.isLoading);
+ const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const searchParams = useSearchParams();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
@@ -21,7 +37,10 @@ function LoginPageContent() {
setError("");
setSubmitting(true);
try {
- await login(email, name || undefined, searchParams.get("next") || undefined);
+ await login(email, name || undefined);
+ const wsList = await api.listWorkspaces();
+ await hydrateWorkspace(wsList);
+ router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError("Login failed. Make sure the server is running.");
setSubmitting(false);
@@ -30,38 +49,51 @@ function LoginPageContent() {
return (
-
+
+
+ Multica
+ AI-native task management
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx
index 406f4713..79428b46 100644
--- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx
+++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import Link from "next/link";
-import { usePathname } from "next/navigation";
+import { usePathname, useRouter } from "next/navigation";
import {
Inbox,
ListTodo,
@@ -26,46 +26,64 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@multica/ui/components/ui/sidebar";
-import { useAuth } from "../../../lib/auth-context";
-import { useTabStore } from "../../../lib/tab-store";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@multica/ui/components/ui/dropdown-menu";
+import { Input } from "@multica/ui/components/ui/input";
+import { Label } from "@multica/ui/components/ui/label";
+import { Button } from "@multica/ui/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@multica/ui/components/ui/dialog";
+import { useAuthStore } from "@/features/auth";
+import { useWorkspaceStore } from "@/features/workspace";
const navItems = [
- { href: "/inbox", label: "Inbox", icon: Inbox, iconKey: "inbox" },
- { href: "/agents", label: "Agents", icon: Bot, iconKey: "agents" },
- { href: "/issues", label: "Issues", icon: ListTodo, iconKey: "issues" },
- {
- href: "/knowledge-base",
- label: "Knowledge Base",
- icon: BookOpen,
- iconKey: "knowledge-base",
- },
+ { href: "/inbox", label: "Inbox", icon: Inbox },
+ { href: "/agents", label: "Agents", icon: Bot },
+ { href: "/issues", label: "Issues", icon: ListTodo },
+ { href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen },
];
export function AppSidebar() {
const pathname = usePathname();
- const {
- user,
- workspace,
- workspaces,
- logout,
- switchWorkspace,
- createWorkspace,
- } = useAuth();
- const { openTab } = useTabStore();
+ const router = useRouter();
+ const user = useAuthStore((s) => s.user);
+ 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 createWorkspace = useWorkspaceStore((s) => s.createWorkspace);
- const [showMenu, setShowMenu] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newName, setNewName] = useState("");
const [newSlug, setNewSlug] = useState("");
const [creating, setCreating] = useState(false);
+ const logout = () => {
+ authLogout();
+ useWorkspaceStore.getState().clearWorkspace();
+ router.push("/login");
+ };
+
const handleNameChange = (value: string) => {
setNewName(value);
setNewSlug(
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
- .replace(/^-|-$/g, "")
+ .replace(/^-|-$/g, ""),
);
};
@@ -95,84 +113,74 @@ export function AppSidebar() {
- setShowMenu(!showMenu)}>
-
-
- {workspace?.name ?? "Multica"}
-
-
-
+
+
+
+
+ {workspace?.name ?? "Multica"}
+
+
+
+ }
+ />
+
+
+
+ {user?.email}
+
+
+
+
+
+ Workspaces
+
+ {workspaces.map((ws) => (
+ {
+ if (ws.id !== workspace?.id) {
+ switchWorkspace(ws.id);
+ }
+ }}
+ >
+
+ {ws.name.charAt(0).toUpperCase()}
+
+ {ws.name}
+ {ws.id === workspace?.id && (
+
+ )}
+
+ ))}
+ setShowCreateDialog(true)}>
+
+ Create workspace
+
+
+
+
+ }
+ >
+
+ Settings
+
+
+
+ Sign out
+
+
+
+
-
- {showMenu && (
- <>
- setShowMenu(false)}
- />
-
-
- {user?.email}
-
-
-
- Workspaces
-
- {workspaces.map((ws) => (
-
- ))}
-
-
-
setShowMenu(false)}
- className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
- >
-
- Settings
-
-
-
- >
- )}
{/* Navigation */}
@@ -189,12 +197,6 @@ export function AppSidebar() {
}
- onClick={() =>
- openTab(item.href, item.label, {
- replace: true,
- iconKey: item.iconKey,
- })
- }
>
{item.label}
@@ -230,66 +232,53 @@ export function AppSidebar() {
{/* Create Workspace Dialog */}
- {showCreateDialog && (
- <>
-
setShowCreateDialog(false)}
- />
-
-
-
- Create workspace
-
-
- Create a new workspace for your team.
-
+
-
);
@@ -389,74 +391,73 @@ function AddToolDialog({
};
return (
- <>
-
-
-
Add Tool
-
- Connect an external tool for this agent to use.
-
+
{ if (!v) onClose(); }}>
+
+
+ Add Tool
+
+ Connect an external tool for this agent to use.
+
+
-
+
-
-
+
+
Cancel
-
-
+
Add
-
-
-
- >
+
+
+
+
);
}
@@ -507,22 +508,23 @@ function ToolsTab({
{isDirty && (
-
{saving ? "Saving..." : "Save"}
-
+
)}
-
setShowAdd(true)}
- className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Add Tool
-
+
@@ -530,13 +532,14 @@ function ToolsTab({
No tools configured
-
setShowAdd(true)}
- className="mt-3 flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
+ size="xs"
+ className="mt-3"
>
Add Tool
-
+
) : (
@@ -563,22 +566,26 @@ function ToolsTab({
)}
- toggleConnect(tool.id)}
- className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
+ className={
tool.connected
- ? "bg-green-500/10 text-green-600"
+ ? "bg-success/10 text-success"
: "bg-muted text-muted-foreground hover:bg-accent"
- }`}
+ }
>
{tool.connected ? "Connected" : "Connect"}
-
-
+ removeTool(tool.id)}
- className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-red-500"
+ className="text-muted-foreground hover:text-destructive"
>
-
+
))}
@@ -661,14 +668,14 @@ function TriggersTab({
{isDirty && (
-
{saving ? "Saving..." : "Save"}
-
+
)}
@@ -710,22 +717,24 @@ function TriggersTab({
}`}
/>
- removeTrigger(trigger.id)}
- className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-red-500"
+ className="text-muted-foreground hover:text-destructive"
>
-
+
{trigger.type === "scheduled" && (
@@ -762,20 +771,24 @@ function TriggersTab({
- addTrigger("on_assign")}
- className="flex items-center gap-1.5 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
+ className="border-dashed text-muted-foreground hover:text-foreground"
>
Add On Assign
-
-
+ addTrigger("scheduled")}
- className="flex items-center gap-1.5 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
+ className="border-dashed text-muted-foreground hover:text-foreground"
>
Add Scheduled
-
+
);
@@ -909,12 +922,13 @@ function AgentDetail({
)}
-
setShowMenu(!showMenu)}
- className="rounded-md p-1.5 hover:bg-accent"
>
-
+
{showMenu && (
<>
setShowMenu(false)} />
@@ -924,7 +938,7 @@ function AgentDetail({
setShowMenu(false);
setConfirmDelete(true);
}}
- className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive hover:bg-accent"
>
Delete Agent
@@ -978,15 +992,11 @@ function AgentDetail({
{/* Delete Confirmation */}
{confirmDelete && (
- <>
-
setConfirmDelete(false)}
- />
-
+
{ if (!v) setConfirmDelete(false); }}>
+
-
-
+
Delete agent?
@@ -995,25 +1005,22 @@ function AgentDetail({
-
- setConfirmDelete(false)}
- className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
- >
+
+ setConfirmDelete(false)}>
Cancel
-
-
+ {
setConfirmDelete(false);
onDelete(agent.id);
}}
- className="rounded-md bg-red-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-600"
>
Delete
-
-
-
- >
+
+
+
+
)}
);
@@ -1024,7 +1031,10 @@ function AgentDetail({
// ---------------------------------------------------------------------------
export default function AgentsPage() {
- const { agents, refreshAgents, workspace, isLoading } = useAuth();
+ const isLoading = useAuthStore((s) => s.isLoading);
+ const workspace = useWorkspaceStore((s) => s.workspace);
+ const agents = useWorkspaceStore((s) => s.agents);
+ const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const [selectedId, setSelectedId] = useState
("");
const [showCreate, setShowCreate] = useState(false);
const [runtimes, setRuntimes] = useState([]);
@@ -1090,24 +1100,26 @@ export default function AgentsPage() {
Agents
-
setShowCreate(true)}
- className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-accent"
>
-
+
{agents.length === 0 ? (
No agents yet
-
setShowCreate(true)}
- className="mt-3 flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
+ size="xs"
+ className="mt-3"
>
Create Agent
-
+
) : (
@@ -1136,13 +1148,14 @@ export default function AgentsPage() {
Select an agent to view details
-
setShowCreate(true)}
- className="mt-3 flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
+ size="xs"
+ className="mt-3"
>
Create Agent
-
+
)}
diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx
index 8b729d7d..da8b1237 100644
--- a/apps/web/app/(dashboard)/inbox/page.tsx
+++ b/apps/web/app/(dashboard)/inbox/page.tsx
@@ -11,8 +11,9 @@ import {
ArrowRightLeft,
} from "lucide-react";
import type { InboxItem, InboxItemType, InboxSeverity, InboxNewPayload } from "@multica/types";
-import { api } from "../../../lib/api";
-import { useWSEvent } from "../../../lib/ws-context";
+import { Button } from "@multica/ui/components/ui/button";
+import { api } from "@/shared/api";
+import { useWSEvent } from "@/features/realtime";
// ---------------------------------------------------------------------------
// Helpers
@@ -34,8 +35,8 @@ const typeIcons: Record
= {
};
const severityColors: Record = {
- action_required: "text-red-500",
- attention: "text-yellow-500",
+ action_required: "text-destructive",
+ attention: "text-warning",
info: "text-muted-foreground",
};
@@ -124,12 +125,14 @@ function InboxDetail({
{!item.read && (
-
onMarkRead(item.id)}
- className="shrink-0 rounded-md border px-2 py-1 text-xs hover:bg-accent"
+ className="shrink-0"
>
Mark read
-
+
)}
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
index 7344ff45..6e23b41f 100644
--- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
+++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
@@ -27,16 +27,27 @@ vi.mock("next/link", () => ({
),
}));
-// Mock auth context
-vi.mock("../../../../lib/auth-context", () => ({
- useAuth: () => ({
- user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
- workspace: { id: "ws-1", name: "Test WS" },
- members: [
- { user_id: "user-1", name: "Test User", email: "test@multica.ai" },
- ],
- agents: [{ id: "agent-1", name: "Claude Agent" }],
- isLoading: false,
+// Mock auth store
+vi.mock("@/features/auth", () => ({
+ useAuthStore: (selector: (s: any) => any) =>
+ selector({
+ user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
+ isLoading: false,
+ }),
+}));
+
+// Mock workspace feature
+vi.mock("@/features/workspace", () => ({
+ useWorkspaceStore: (selector: (s: any) => any) =>
+ selector({
+ workspace: { id: "ws-1", name: "Test WS" },
+ workspaces: [{ id: "ws-1", name: "Test WS" }],
+ members: [{ user_id: "user-1", name: "Test User", email: "test@multica.ai" }],
+ agents: [{ id: "agent-1", name: "Claude Agent" }],
+ }),
+ useActorName: () => ({
+ getMemberName: (id: string) => (id === "user-1" ? "Test User" : "Unknown"),
+ getAgentName: (id: string) => (id === "agent-1" ? "Claude Agent" : "Unknown Agent"),
getActorName: (type: string, id: string) => {
if (type === "member" && id === "user-1") return "Test User";
if (type === "agent" && id === "agent-1") return "Claude Agent";
@@ -51,7 +62,7 @@ vi.mock("../../../../lib/auth-context", () => ({
}));
// Mock ws-context
-vi.mock("../../../../lib/ws-context", () => ({
+vi.mock("@/features/realtime", () => ({
useWSEvent: () => {},
}));
@@ -60,14 +71,6 @@ vi.mock("@multica/ui/components/ui/calendar", () => ({
Calendar: () => null,
}));
-// Mock tab-store
-vi.mock("../../../../lib/tab-store", () => ({
- useTabStore: () => ({
- updateTabTitle: vi.fn(),
- activeTabId: "tab-1",
- }),
-}));
-
// Mock api
const mockGetIssue = vi.hoisted(() => vi.fn());
const mockListComments = vi.hoisted(() => vi.fn());
@@ -77,7 +80,7 @@ const mockDeleteComment = vi.hoisted(() => vi.fn());
const mockDeleteIssue = vi.hoisted(() => vi.fn());
const mockUpdateIssue = vi.hoisted(() => vi.fn());
-vi.mock("../../../../lib/api", () => ({
+vi.mock("@/shared/api", () => ({
api: {
getIssue: (...args: any[]) => mockGetIssue(...args),
listComments: (...args: any[]) => mockListComments(...args),
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx
index c6789ed1..76e56bca 100644
--- a/apps/web/app/(dashboard)/issues/[id]/page.tsx
+++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx
@@ -4,7 +4,6 @@ import { use, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
- Bot,
ChevronRight,
GitBranch,
Link2,
@@ -31,12 +30,15 @@ import {
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
+import { Button } from "@multica/ui/components/ui/button";
+import { Input } from "@multica/ui/components/ui/input";
+import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
-import { StatusPicker, PriorityPicker, AssigneePicker } from "../_components";
-import { api } from "../../../../lib/api";
-import { useAuth } from "../../../../lib/auth-context";
-import { useWSEvent } from "../../../../lib/ws-context";
-import { useTabStore } from "../../../../lib/tab-store";
+import { StatusPicker, PriorityPicker, AssigneePicker } from "@/features/issues/components";
+import { api } from "@/shared/api";
+import { useAuthStore } from "@/features/auth";
+import { useActorName } from "@/features/workspace";
+import { useWSEvent } from "@/features/realtime";
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types";
// ---------------------------------------------------------------------------
@@ -62,42 +64,6 @@ function shortDate(date: string | null): string {
});
}
-// ---------------------------------------------------------------------------
-// Avatar
-// ---------------------------------------------------------------------------
-
-function ActorAvatar({
- actorType,
- actorId,
- size = 20,
-}: {
- actorType: string;
- actorId: string;
- size?: number;
-}) {
- const { getActorName, getActorInitials } = useAuth();
- const name = getActorName(actorType, actorId);
- const initials = getActorInitials(actorType, actorId);
- const isAgent = actorType === "agent";
- return (
-
- {isAgent ? (
-
- ) : (
- initials
- )}
-
- );
-}
-
// ---------------------------------------------------------------------------
// Property row
// ---------------------------------------------------------------------------
@@ -138,7 +104,7 @@ function DueDatePicker({
{date ? (
-
+
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
) : (
@@ -156,15 +122,17 @@ function DueDatePicker({
/>
{date && (
- {
onUpdate({ due_date: null });
setOpen(false);
}}
- className="text-xs text-muted-foreground hover:text-foreground"
+ className="text-muted-foreground hover:text-foreground"
>
Clear date
-
+
)}
@@ -207,12 +175,14 @@ function AcceptanceCriteriaEditor({
•
{item}
- removeItem(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
-
+
))}
@@ -268,18 +238,20 @@ function ContextRefsEditor({
{isUrl(ref) ? (
-
+
{ref}
) : (
{ref}
)}
-
removeRef(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
-
+
))}
@@ -358,41 +330,44 @@ function RepositoryEditor({
Repository
- setUrl(e.target.value)}
placeholder="https://github.com/org/repo"
- className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+ className="text-xs"
autoFocus
/>
- setBranch(e.target.value)}
placeholder="Branch"
- className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+ className="text-xs"
/>
- setPath(e.target.value)}
placeholder="Path"
- className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+ className="text-xs"
/>
{repository && (
-
Remove
-
+
)}
-
Save
-
+
@@ -410,8 +385,8 @@ export default function IssueDetailPage({
}) {
const { id } = use(params);
const router = useRouter();
- const { user, getActorName } = useAuth();
- const { updateTabTitle, activeTabId, closeTabByPath } = useTabStore();
+ const user = useAuthStore((s) => s.user);
+ const { getActorName, getActorInitials } = useActorName();
const [issue, setIssue] = useState(null);
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
@@ -434,13 +409,6 @@ export default function IssueDetailPage({
.finally(() => setLoading(false));
}, [id]);
- // Sync tab title with loaded issue title
- useEffect(() => {
- if (issue?.title && activeTabId) {
- updateTabTitle(activeTabId, issue.title);
- }
- }, [issue?.title, activeTabId, updateTabTitle]);
-
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!commentText.trim() || submitting) return;
@@ -474,7 +442,6 @@ export default function IssueDetailPage({
try {
await api.deleteIssue(issue!.id);
toast.success("Issue deleted");
- closeTabByPath(`/issues/${id}`);
router.push("/issues");
} catch {
toast.error("Failed to delete issue");
@@ -576,7 +543,7 @@ export default function IssueDetailPage({
}
+ render={}
>
@@ -644,6 +611,8 @@ export default function IssueDetailPage({
actorType={comment.author_type}
actorId={comment.author_id}
size={28}
+ getName={getActorName}
+ getInitials={getActorInitials}
/>
{getActorName(comment.author_type, comment.author_id)}
@@ -653,18 +622,22 @@ export default function IssueDetailPage({
{isOwn && (
-
startEditComment(comment)}
- className="p-1 text-muted-foreground hover:text-foreground rounded"
+ className="text-muted-foreground hover:text-foreground"
>
-
-
+ handleDeleteComment(comment.id)}
- className="p-1 text-muted-foreground hover:text-destructive rounded"
+ className="text-muted-foreground hover:text-destructive"
>
-
+
)}
@@ -691,20 +664,20 @@ export default function IssueDetailPage({
{/* Comment input */}
@@ -748,6 +721,8 @@ export default function IssueDetailPage({
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
+ getName={getActorName}
+ getInitials={getActorInitials}
/>
{getActorName(issue.creator_type, issue.creator_id)}
diff --git a/apps/web/app/(dashboard)/issues/_components/index.ts b/apps/web/app/(dashboard)/issues/_components/index.ts
deleted file mode 100644
index 8b22c442..00000000
--- a/apps/web/app/(dashboard)/issues/_components/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./icons";
-export * from "./pickers";
diff --git a/apps/web/app/(dashboard)/issues/_data/config.ts b/apps/web/app/(dashboard)/issues/_data/config.ts
deleted file mode 100644
index 643f0512..00000000
--- a/apps/web/app/(dashboard)/issues/_data/config.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { IssueStatus, IssuePriority } from "@multica/types";
-
-export const STATUS_CONFIG: Record<
- IssueStatus,
- { label: string; iconColor: string }
-> = {
- backlog: { label: "Backlog", iconColor: "text-muted-foreground" },
- todo: { label: "Todo", iconColor: "text-muted-foreground" },
- in_progress: { label: "In Progress", iconColor: "text-yellow-500" },
- in_review: { label: "In Review", iconColor: "text-blue-500" },
- done: { label: "Done", iconColor: "text-green-500" },
- blocked: { label: "Blocked", iconColor: "text-red-500" },
- cancelled: { label: "Cancelled", iconColor: "text-muted-foreground/50" },
-};
-
-export const PRIORITY_CONFIG: Record<
- IssuePriority,
- { label: string; bars: number; color: string }
-> = {
- urgent: { label: "Urgent", bars: 4, color: "text-orange-500" },
- high: { label: "High", bars: 3, color: "text-orange-400" },
- medium: { label: "Medium", bars: 2, color: "text-yellow-500" },
- low: { label: "Low", bars: 1, color: "text-blue-400" },
- none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
-};
diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx
index ca753fc9..7ceb96d6 100644
--- a/apps/web/app/(dashboard)/issues/page.test.tsx
+++ b/apps/web/app/(dashboard)/issues/page.test.tsx
@@ -26,16 +26,11 @@ vi.mock("next/link", () => ({
),
}));
-// Mock auth context
-vi.mock("../../../lib/auth-context", () => ({
- useAuth: () => ({
- user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
- workspace: { id: "ws-1", name: "Test WS" },
- members: [
- { user_id: "user-1", name: "Test User", email: "test@multica.ai" },
- ],
- agents: [{ id: "agent-1", name: "Claude Agent" }],
- isLoading: false,
+// Mock workspace feature
+vi.mock("@/features/workspace", () => ({
+ useActorName: () => ({
+ getMemberName: (id: string) => (id === "user-1" ? "Test User" : "Unknown"),
+ getAgentName: (id: string) => (id === "agent-1" ? "Claude Agent" : "Unknown Agent"),
getActorName: (type: string, id: string) =>
type === "member" ? "Test User" : "Claude Agent",
getActorInitials: () => "TU",
@@ -43,32 +38,18 @@ vi.mock("../../../lib/auth-context", () => ({
}));
// Mock WebSocket context
-vi.mock("../../../lib/ws-context", () => ({
+vi.mock("@/features/realtime", () => ({
useWSEvent: vi.fn(),
useWS: () => ({ subscribe: vi.fn(() => () => {}) }),
WSProvider: ({ children }: { children: React.ReactNode }) => children,
}));
-// Mock tab-store
-vi.mock("../../../lib/tab-store", () => ({
- useTabStore: () => ({
- updateTabTitle: vi.fn(),
- activeTabId: "tab-1",
- openTab: vi.fn(),
- }),
-}));
-
-// Mock tab-link to avoid TabProvider dependency
-vi.mock("../_components/tab-link", () => ({
- TabLink: ({ children, href, ...props }: any) => {children},
-}));
-
// Mock api
const mockListIssues = vi.fn();
const mockCreateIssue = vi.fn();
const mockUpdateIssue = vi.fn();
-vi.mock("../../../lib/api", () => ({
+vi.mock("@/shared/api", () => ({
api: {
listIssues: (...args: any[]) => mockListIssues(...args),
createIssue: (...args: any[]) => mockCreateIssue(...args),
diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx
index 4e3e5cae..31157240 100644
--- a/apps/web/app/(dashboard)/issues/page.tsx
+++ b/apps/web/app/(dashboard)/issues/page.tsx
@@ -2,13 +2,10 @@
import { useState, useCallback, useEffect } from "react";
import Link from "next/link";
-import { TabLink } from "../_components/tab-link";
-import { useTabStore } from "../../../lib/tab-store";
import {
Columns3,
List,
Plus,
- Bot,
} from "lucide-react";
import {
DndContext,
@@ -24,7 +21,7 @@ import {
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Issue, IssueStatus, IssuePriority } from "@multica/types";
-import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER } from "./_config";
+import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER } from "@/features/issues/config";
import {
Dialog,
DialogContent,
@@ -33,42 +30,23 @@ import {
DialogFooter,
DialogTrigger,
} from "@multica/ui/components/ui/dialog";
-import { StatusIcon, PriorityIcon } from "./_components";
-import { api } from "../../../lib/api";
-import { useAuth } from "../../../lib/auth-context";
-import { useWSEvent } from "../../../lib/ws-context";
+import { Button } from "@multica/ui/components/ui/button";
+import { Input } from "@multica/ui/components/ui/input";
+import { Textarea } from "@multica/ui/components/ui/textarea";
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from "@multica/ui/components/ui/select";
+import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
+import { StatusIcon, PriorityIcon } from "@/features/issues/components";
+import { api } from "@/shared/api";
+import { useActorName } from "@/features/workspace";
+import { useWSEvent } from "@/features/realtime";
import type { IssueCreatedPayload, IssueUpdatedPayload, IssueDeletedPayload } from "@multica/types";
-function AssigneeAvatar({
- issue,
- size = "sm",
-}: {
- issue: Issue;
- size?: "sm" | "md";
-}) {
- const { getActorName, getActorInitials } = useAuth();
- if (!issue.assignee_type || !issue.assignee_id) return null;
- const name = getActorName(issue.assignee_type, issue.assignee_id);
- const initials = getActorInitials(issue.assignee_type, issue.assignee_id);
- const sizeClass = size === "sm" ? "h-5 w-5 text-[10px]" : "h-6 w-6 text-xs";
- return (
-
- {issue.assignee_type === "agent" ? (
-
- ) : (
- initials
- )}
-
- );
-}
-
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
@@ -81,6 +59,7 @@ function formatDate(date: string): string {
// ---------------------------------------------------------------------------
function BoardCardContent({ issue }: { issue: Issue }) {
+ const { getActorName, getActorInitials } = useActorName();
return (
@@ -90,7 +69,15 @@ function BoardCardContent({ issue }: { issue: Issue }) {
{issue.title}
-
+ {issue.assignee_type && issue.assignee_id && (
+
+ )}
{issue.due_date && (
@@ -135,14 +122,12 @@ function DraggableBoardCard({ issue }: { issue: Issue }) {
if (isDragging) e.stopPropagation();
}}
>
-
-
+
);
}
@@ -276,11 +261,10 @@ function BoardView({
// ---------------------------------------------------------------------------
function ListRow({ issue }: { issue: Issue }) {
+ const { getActorName, getActorInitials } = useActorName();
return (
-
@@ -294,8 +278,16 @@ function ListRow({ issue }: { issue: Issue }) {
{formatDate(issue.due_date)}
)}
-
-
+ {issue.assignee_type && issue.assignee_id && (
+
+ )}
+
);
}
@@ -374,10 +366,10 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
{ setOpen(v); if (!v) reset(); }}>
+
New Issue
-
+
}
/>
@@ -385,7 +377,7 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
New Issue
-
{submitting ? "Creating..." : "Create Issue"}
-
+
@@ -456,7 +444,6 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
type ViewMode = "board" | "list";
export default function IssuesPage() {
- const { closeTabByPath } = useTabStore();
const [view, setView] = useState
("board");
const [issues, setIssues] = useState([]);
const [loading, setLoading] = useState(true);
@@ -503,8 +490,7 @@ export default function IssuesPage() {
useCallback((payload: unknown) => {
const { issue_id } = payload as IssueDeletedPayload;
setIssues((prev) => prev.filter((i) => i.id !== issue_id));
- closeTabByPath(`/issues/${issue_id}`);
- }, [closeTabByPath]),
+ }, []),
);
const handleMoveIssue = useCallback(
@@ -548,50 +534,56 @@ export default function IssuesPage() {
All Issues
- setView("board")}
- className={`flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
+ className={
view === "board"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
- }`}
+ }
>
Board
-
-
+ setView("list")}
- className={`flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
+ className={
view === "list"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
- }`}
+ }
>
List
-
+
-
-
+
+
diff --git a/apps/web/app/(dashboard)/knowledge-base/page.tsx b/apps/web/app/(dashboard)/knowledge-base/page.tsx
index 925d3238..a49168b4 100644
--- a/apps/web/app/(dashboard)/knowledge-base/page.tsx
+++ b/apps/web/app/(dashboard)/knowledge-base/page.tsx
@@ -7,6 +7,8 @@ import {
Search,
Link as LinkIcon,
} from "lucide-react";
+import { Input } from "@multica/ui/components/ui/input";
+import { Button } from "@multica/ui/components/ui/button";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -293,21 +295,21 @@ export default function KnowledgeBasePage() {
{/* Search */}
diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx
index 6f83a92e..60be7c5c 100644
--- a/apps/web/app/(dashboard)/layout.tsx
+++ b/apps/web/app/(dashboard)/layout.tsx
@@ -3,11 +3,10 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
-import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
-import { useAuth } from "../../lib/auth-context";
-import { TabProvider } from "../../lib/tab-store";
+import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
+import { useAuthStore } from "@/features/auth";
+import { useWorkspaceStore } from "@/features/workspace";
import { AppSidebar } from "./_components/app-sidebar";
-import { TabBar } from "./_components/tab-bar";
export default function DashboardLayout({
children,
@@ -15,7 +14,9 @@ export default function DashboardLayout({
children: React.ReactNode;
}) {
const router = useRouter();
- const { user, workspace, isLoading } = useAuth();
+ const user = useAuthStore((s) => s.user);
+ const isLoading = useAuthStore((s) => s.isLoading);
+ const workspace = useWorkspaceStore((s) => s.workspace);
useEffect(() => {
if (!isLoading && !user) {
@@ -34,16 +35,9 @@ export default function DashboardLayout({
if (!user || !workspace) return null;
return (
-
-
-
-
-
-
- {children}
-
-
-
-
+
+
+ {children}
+
);
}
diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx
index da0c2678..27ecc064 100644
--- a/apps/web/app/(dashboard)/settings/page.tsx
+++ b/apps/web/app/(dashboard)/settings/page.tsx
@@ -3,8 +3,20 @@
import { useEffect, useState } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react";
import type { MemberWithUser, MemberRole } from "@multica/types";
-import { useAuth } from "../../../lib/auth-context";
-import { api } from "../../../lib/api";
+import { Input } from "@multica/ui/components/ui/input";
+import { Textarea } from "@multica/ui/components/ui/textarea";
+import { Label } from "@multica/ui/components/ui/label";
+import { Button } from "@multica/ui/components/ui/button";
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from "@multica/ui/components/ui/select";
+import { useAuthStore } from "@/features/auth";
+import { useWorkspaceStore } from "@/features/workspace";
+import { api } from "@/shared/api";
const roleConfig: Record
= {
owner: { label: "Owner", icon: Crown },
@@ -49,16 +61,14 @@ function MemberRow({
{member.email}
{canEditRole ? (
-
+
) : (
@@ -66,30 +76,29 @@ function MemberRow({
)}
{canRemove && (
-
-
+
)}
);
}
export default function SettingsPage() {
- const {
- user,
- workspace,
- members,
- updateWorkspace,
- updateCurrentUser,
- refreshMembers,
- leaveWorkspace,
- deleteWorkspace,
- } = useAuth();
+ const user = useAuthStore((s) => s.user);
+ const setUser = useAuthStore((s) => s.setUser);
+ const workspace = useWorkspaceStore((s) => s.workspace);
+ const members = useWorkspaceStore((s) => s.members);
+ const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
+ const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
+ const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
+ const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(
@@ -152,7 +161,7 @@ export default function SettingsPage() {
name: profileName,
avatar_url: avatarUrl || undefined,
});
- updateCurrentUser(updated);
+ setUser(updated);
setProfileSaved(true);
setTimeout(() => setProfileSaved(false), 2000);
} catch (e) {
@@ -259,43 +268,43 @@ export default function SettingsPage() {
@@ -309,34 +318,34 @@ export default function SettingsPage() {
-
-
+
Description
-
-
-
+
Context
-
+
-
+
Slug
-
+
{workspace.slug}
{workspaceError && (
- {workspaceError}
+ {workspaceError}
)}
{saved && (
- Saved!
+ Saved!
)}
-
{saving ? "Saving..." : "Save"}
-
+
{!canManageWorkspace && (
@@ -390,7 +399,7 @@ export default function SettingsPage() {
{memberError && (
-
{memberError}
+
{memberError}
)}
{canManageWorkspace && (
@@ -400,29 +409,26 @@ export default function SettingsPage() {
Add member
- setInviteEmail(e.target.value)}
placeholder="user@company.com"
- className="rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
-
- setInviteRole(value as MemberRole)}>
+
+
+ Member
+ Admin
+ {isOwner && Owner}
+
+
+
{inviteLoading ? "Adding..." : "Add"}
-
+
)}
@@ -460,30 +466,32 @@ export default function SettingsPage() {
Remove yourself from this workspace.
-
{memberActionId === "leave" ? "Leaving..." : "Leave workspace"}
-
+
{isOwner && (
-
Delete workspace
+
Delete workspace
Permanently delete this workspace and its data.
-
{memberActionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
-
+
)}
diff --git a/apps/web/app/favicon.ico/route.ts b/apps/web/app/favicon.ico/route.ts
new file mode 100644
index 00000000..00890acb
--- /dev/null
+++ b/apps/web/app/favicon.ico/route.ts
@@ -0,0 +1,3 @@
+export function GET(request: Request) {
+ return Response.redirect(new URL("/favicon.svg", request.url), 308);
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 5a544793..8c4bc1b7 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,13 +1,17 @@
import type { Metadata } from "next";
import { ThemeProvider } from "@multica/ui/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
-import { AuthProvider } from "../lib/auth-context";
-import { WSProvider } from "../lib/ws-context";
+import { AuthInitializer } from "@/features/auth";
+import { WSProvider } from "@/features/realtime";
import "./globals.css";
export const metadata: Metadata = {
title: "Multica",
description: "AI-native task management",
+ icons: {
+ icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
+ shortcut: ["/favicon.svg"],
+ },
};
export default function RootLayout({
@@ -24,9 +28,9 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
-
+
{children}
-
+