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

- -
- setName(e.target.value)} - className="w-full rounded-md border bg-background px-3 py-2 text-sm" - /> - setEmail(e.target.value)} - className="w-full rounded-md border bg-background px-3 py-2 text-sm" - required - /> -
- - {error &&

{error}

} - - -
+ + + Multica + AI-native task management + + +
+
+ + setName(e.target.value)} + /> +
+
+ + setEmail(e.target.value)} + required + /> +
+ {error && ( +

{error}

+ )} +
+
+ + + +
); } 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. -

+ + + + Create workspace + + Create a new workspace for your team. + + +
+
+ + handleNameChange(e.target.value)} + placeholder="My Workspace" + className="mt-1" + />
-
-
- - handleNameChange(e.target.value)} - placeholder="My Workspace" - className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" - /> -
-
- - setNewSlug(e.target.value)} - placeholder="my-workspace" - className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" - /> -
-
-
- - +
+ + setNewSlug(e.target.value)} + placeholder="my-workspace" + className="mt-1" + />
- - )} + + + + + +
); } diff --git a/apps/web/app/(dashboard)/_components/tab-bar.tsx b/apps/web/app/(dashboard)/_components/tab-bar.tsx deleted file mode 100644 index 7b5d5327..00000000 --- a/apps/web/app/(dashboard)/_components/tab-bar.tsx +++ /dev/null @@ -1,271 +0,0 @@ -"use client"; - -import { useCallback, useState, useEffect, useRef } from "react"; -import { - DndContext, - PointerSensor, - useSensor, - useSensors, - closestCenter, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - horizontalListSortingStrategy, - useSortable, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { - Plus, - X, - Inbox, - Bot, - ListTodo, - BookOpen, - Settings, - FileText, -} from "lucide-react"; -import { useTabStore, type Tab } from "../../../lib/tab-store"; - -// --------------------------------------------------------------------------- -// Icon lookup -// --------------------------------------------------------------------------- - -const TAB_ICONS: Record = { - inbox: Inbox, - agents: Bot, - issues: ListTodo, - "knowledge-base": BookOpen, - settings: Settings, -}; - -function TabIcon({ iconKey }: { iconKey?: string }) { - const Icon = iconKey ? TAB_ICONS[iconKey] : undefined; - if (!Icon) return ; - return ; -} - -// --------------------------------------------------------------------------- -// Context Menu -// --------------------------------------------------------------------------- - -function TabContextMenu({ - x, - y, - tabId, - onClose, -}: { - x: number; - y: number; - tabId: string; - onClose: () => void; -}) { - const { tabs, closeTab } = useTabStore(); - const menuRef = useRef(null); - const canClose = tabs.length > 1; - - useEffect(() => { - const handleClick = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - const handleEsc = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("mousedown", handleClick); - document.addEventListener("keydown", handleEsc); - return () => { - document.removeEventListener("mousedown", handleClick); - document.removeEventListener("keydown", handleEsc); - }; - }, [onClose]); - - const handleClose = () => { - if (canClose) closeTab(tabId); - onClose(); - }; - - const handleCloseOthers = () => { - tabs.forEach((t) => { - if (t.id !== tabId && tabs.length > 1) closeTab(t.id); - }); - onClose(); - }; - - return ( -
- - -
- ); -} - -// --------------------------------------------------------------------------- -// SortableTab -// --------------------------------------------------------------------------- - -function SortableTab({ - tab, - isActive, - canClose, - onContextMenu, -}: { - tab: Tab; - isActive: boolean; - canClose: boolean; - onContextMenu: (e: React.MouseEvent, tabId: string) => void; -}) { - const { activateTab, closeTab } = useTabStore(); - - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: tab.id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - const handleClick = () => { - if (!isDragging) { - activateTab(tab.id); - } - }; - - const handleClose = (e: React.MouseEvent) => { - e.stopPropagation(); - closeTab(tab.id); - }; - - return ( - - ); -} - -// --------------------------------------------------------------------------- -// TabBar -// --------------------------------------------------------------------------- - -export function TabBar() { - const { tabs, activeTabId, reorderTabs, openTab } = useTabStore(); - const [contextMenu, setContextMenu] = useState<{ - x: number; - y: number; - tabId: string; - } | null>(null); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 5 }, - }) - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - const oldIndex = tabs.findIndex((t) => t.id === active.id); - const newIndex = tabs.findIndex((t) => t.id === over.id); - if (oldIndex !== -1 && newIndex !== -1) { - reorderTabs(oldIndex, newIndex); - } - }, - [tabs, reorderTabs] - ); - - const handleNewTab = () => { - openTab("/issues", "All Issues", { replace: false, iconKey: "issues" }); - }; - - const handleContextMenu = (e: React.MouseEvent, tabId: string) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY, tabId }); - }; - - return ( -
- - t.id)} - strategy={horizontalListSortingStrategy} - > - {tabs.map((tab) => ( - 1} - onContextMenu={handleContextMenu} - /> - ))} - - - - - {contextMenu && ( - setContextMenu(null)} - /> - )} -
- ); -} diff --git a/apps/web/app/(dashboard)/_components/tab-link.tsx b/apps/web/app/(dashboard)/_components/tab-link.tsx deleted file mode 100644 index 5f8a50fa..00000000 --- a/apps/web/app/(dashboard)/_components/tab-link.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useTabStore } from "../../../lib/tab-store"; - -export function TabLink({ - href, - title, - iconKey, - children, - ...props -}: { - href: string; - title: string; - iconKey?: string; - children: React.ReactNode; -} & Omit, "onClick" | "href">) { - const { openTab } = useTabStore(); - - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - openTab(href, title, { replace: false, iconKey }); - }; - - return ( - - {children} - - ); -} diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 8879788d..c762a8d7 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -12,7 +12,6 @@ import { Timer, Trash2, Save, - X, Key, Link2, Clock, @@ -34,9 +33,22 @@ import type { CreateAgentRequest, UpdateAgentRequest, } from "@multica/types"; -import { api } from "../../../lib/api"; -import { useAuth } from "../../../lib/auth-context"; -import { useWSEvent } from "../../../lib/ws-context"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@multica/ui/components/ui/dialog"; +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 { Label } from "@multica/ui/components/ui/label"; +import { api } from "@/shared/api"; +import { useAuthStore } from "@/features/auth"; +import { useWorkspaceStore } from "@/features/workspace"; +import { useWSEvent } from "@/features/realtime"; // --------------------------------------------------------------------------- // Helpers @@ -44,18 +56,18 @@ import { useWSEvent } from "../../../lib/ws-context"; const statusConfig: Record = { idle: { label: "Idle", color: "text-muted-foreground", dot: "bg-muted-foreground" }, - working: { label: "Working", color: "text-green-600", dot: "bg-green-500" }, - blocked: { label: "Blocked", color: "text-yellow-600", dot: "bg-yellow-500" }, - error: { label: "Error", color: "text-red-600", dot: "bg-red-500" }, + working: { label: "Working", color: "text-success", dot: "bg-success" }, + blocked: { label: "Blocked", color: "text-warning", dot: "bg-warning" }, + error: { label: "Error", color: "text-destructive", dot: "bg-destructive" }, offline: { label: "Offline", color: "text-muted-foreground/50", dot: "bg-muted-foreground/40" }, }; const taskStatusConfig: Record = { queued: { label: "Queued", icon: Clock, color: "text-muted-foreground" }, - dispatched: { label: "Dispatched", icon: Play, color: "text-blue-500" }, - running: { label: "Running", icon: Loader2, color: "text-green-500" }, - completed: { label: "Completed", icon: CheckCircle2, color: "text-green-600" }, - failed: { label: "Failed", icon: XCircle, color: "text-red-500" }, + dispatched: { label: "Dispatched", icon: Play, color: "text-info" }, + running: { label: "Running", icon: Loader2, color: "text-success" }, + completed: { label: "Completed", icon: CheckCircle2, color: "text-success" }, + failed: { label: "Failed", icon: XCircle, color: "text-destructive" }, cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground" }, }; @@ -120,55 +132,49 @@ function CreateAgentDialog({ }; return ( - <> -
-
-
-

Create Agent

- -
-

- Create a new AI agent for your workspace. -

+ { if (!v) onClose(); }}> + + + Create Agent + + Create a new AI agent for your workspace. + + -
+
- - Name + setName(e.target.value)} placeholder="e.g. Deep Research Agent" - className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + className="mt-1" onKeyDown={(e) => e.key === "Enter" && handleSubmit()} />
- - Description + setDescription(e.target.value)} placeholder="What does this agent do?" - className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + className="mt-1" />
- +
-
- + {runtimeOpen && ( <> @@ -217,7 +223,7 @@ function CreateAgentDialog({
{device.name} {device.runtime_mode === "cloud" && ( - + Cloud )} @@ -226,7 +232,7 @@ function CreateAgentDialog({
@@ -238,23 +244,19 @@ function CreateAgentDialog({
-
- - -
-
- + + + + ); } @@ -340,21 +342,21 @@ function SkillsTab({

{isDirty && ( - + )}
-