diff --git a/CLAUDE.md b/CLAUDE.md index f105d3d3..d7c67f65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,62 @@ docker compose down # Stop PostgreSQL ## 7. Minimum Pre-Push Checks ```bash -pnpm typecheck -pnpm test -make test +make check # Runs all checks: typecheck, unit tests, Go tests, E2E +``` + +For individual checks during development: +```bash +pnpm typecheck # TypeScript type errors only +pnpm test # TS unit tests only (Vitest) +make test # Go tests only +pnpm exec playwright test # E2E only (requires backend + frontend running) +``` + +## 8. AI Agent Verification Loop + +After writing or modifying code, always run the full verification pipeline: + +```bash +make check +``` + +This runs all checks in sequence: +1. TypeScript typecheck (`pnpm typecheck`) +2. TypeScript unit tests (`pnpm test`) +3. Go tests (`go test ./...`) +4. E2E tests (auto-starts backend + frontend if needed, runs Playwright) + +**Workflow:** +- Write code to satisfy the requirement +- Run `make check` +- If any step fails, read the error output, fix the code, and re-run `make check` +- Repeat until all checks pass +- Only then consider the task complete + +**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete. + +## 9. E2E Test Patterns + +E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown: + +```typescript +import { loginAsDefault, createTestApi } from "./helpers"; +import type { TestApiClient } from "./fixtures"; + +let api: TestApiClient; + +test.beforeEach(async ({ page }) => { + api = await createTestApi(); // logged-in API client + await loginAsDefault(page); // browser session +}); + +test.afterEach(async () => { + await api.cleanup(); // delete any data created during the test +}); + +test("example", async ({ page }) => { + const issue = await api.createIssue("Test Issue"); // create via API + await page.goto(`/issues/${issue.id}`); // test via UI + // api.cleanup() in afterEach removes the issue +}); ``` diff --git a/Makefile b/Makefile index 1b3ba01c..3736cd27 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop +.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop check # ---------- One-click commands ---------- @@ -7,11 +7,15 @@ setup: @echo "==> Installing dependencies..." pnpm install @echo "==> Starting PostgreSQL..." - docker compose up -d - @echo "==> Waiting for PostgreSQL to be ready..." - @until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ - sleep 1; \ - done + @if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then \ + echo " PostgreSQL already running, skipping docker compose up."; \ + else \ + docker compose up -d; \ + echo "==> Waiting for PostgreSQL to be ready..."; \ + until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ + sleep 1; \ + done; \ + fi @echo "==> Running migrations..." cd server && go run ./cmd/migrate up @echo "==> Seeding data..." @@ -21,10 +25,14 @@ setup: # Start all services (backend + frontend) start: - @docker compose up -d - @until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ - sleep 1; \ - done + @if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then \ + echo "PostgreSQL already running, skipping docker compose up."; \ + else \ + docker compose up -d; \ + until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ + sleep 1; \ + done; \ + fi @echo "Starting backend and frontend..." @trap 'kill 0' EXIT; \ (cd server && go run ./cmd/server) & \ @@ -39,6 +47,10 @@ stop: docker compose down @echo "✓ All services stopped." +# Full verification: typecheck + unit tests + Go tests + E2E +check: + @bash scripts/check.sh + # ---------- Individual commands ---------- # Go server diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 7ebb0c00..c8f03704 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Bot, Cloud, @@ -9,8 +9,9 @@ import { Zap, ListTodo, } from "lucide-react"; -import type { Agent, AgentStatus } from "@multica/types"; +import type { Agent, AgentStatus, AgentStatusPayload } from "@multica/types"; import { api } from "../../../lib/api"; +import { useWSEvent } from "../../../lib/ws-context"; // --------------------------------------------------------------------------- // Helpers @@ -178,6 +179,14 @@ export default function AgentsPage() { .finally(() => setLoading(false)); }, []); + useWSEvent( + "agent:status", + useCallback((payload: unknown) => { + const { agent } = payload as AgentStatusPayload; + setAgents((prev) => prev.map((a) => (a.id === agent.id ? agent : a))); + }, []), + ); + const selected = agents.find((a) => a.id === selectedId) ?? null; if (loading) { diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index a2898b11..8b729d7d 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { AlertCircle, Bot, @@ -10,8 +10,9 @@ import { MessageSquare, ArrowRightLeft, } from "lucide-react"; -import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types"; +import type { InboxItem, InboxItemType, InboxSeverity, InboxNewPayload } from "@multica/types"; import { api } from "../../../lib/api"; +import { useWSEvent } from "../../../lib/ws-context"; // --------------------------------------------------------------------------- // Helpers @@ -167,6 +168,17 @@ export default function InboxPage() { .finally(() => setLoading(false)); }, []); + useWSEvent( + "inbox:new", + useCallback((payload: unknown) => { + const { item } = payload as InboxNewPayload; + setItems((prev) => { + if (prev.some((i) => i.id === item.id)) return prev; + return [item, ...prev]; + }); + }, []), + ); + const handleMarkRead = async (id: string) => { try { await api.markInboxRead(id); diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index 9c70d5ac..0f0ec55a 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -111,6 +111,9 @@ export default function IssueDetailPage({ const [submitting, setSubmitting] = useState(false); useEffect(() => { + setIssue(null); + setComments([]); + setLoading(true); Promise.all([api.getIssue(id), api.listComments(id)]) .then(([iss, cmts]) => { setIssue(iss); diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx index 127b606a..509bd2ee 100644 --- a/apps/web/app/(dashboard)/issues/page.tsx +++ b/apps/web/app/(dashboard)/issues/page.tsx @@ -215,7 +215,7 @@ function DroppableColumn({ const { setNodeRef, isOver } = useDroppable({ id: status }); return ( -