diff --git a/.gitignore b/.gitignore index 1c03660d..34311237 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,11 @@ coverage # Go server/bin/ server/tmp/ +server/migrate + +# Test artifacts +test-results/ +apps/web/test-results/ # context (agent workspace) .context diff --git a/CLAUDE.md b/CLAUDE.md index f6e7cd23..f105d3d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,23 +21,30 @@ Multica is an AI-native task management platform — like Linear, but with AI ag ## 3. Core Workflow Commands ```bash +# One-click setup & run +make setup # First-time: install deps, start DB, migrate, seed +make start # Start backend + frontend together +make stop # Stop everything + # Frontend pnpm install -pnpm dev:web # Next.js dev server +pnpm dev:web # Next.js dev server (port 3000) pnpm build # Build all TS packages pnpm typecheck # TypeScript check -pnpm test # TS tests +pnpm test # TS tests (Vitest) # Backend (Go) -make dev # Run Go server with hot-reload +make dev # Run Go server (port 8080) make daemon # Run local daemon make test # Go tests make sqlc # Regenerate sqlc code make migrate-up # Run database migrations make migrate-down # Rollback migrations +make seed # Seed sample data # Infrastructure docker compose up -d # Start PostgreSQL +docker compose down # Stop PostgreSQL ``` ## 4. Coding Rules diff --git a/Makefile b/Makefile index e30aef09..1b3ba01c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,45 @@ -.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean +.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop + +# ---------- One-click commands ---------- + +# First-time setup: install deps, start DB, run migrations, seed data +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 + @echo "==> Running migrations..." + cd server && go run ./cmd/migrate up + @echo "==> Seeding data..." + cd server && go run ./cmd/seed + @echo "" + @echo "✓ Setup complete! Run 'make start' to launch the app." + +# 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 + @echo "Starting backend and frontend..." + @trap 'kill 0' EXIT; \ + (cd server && go run ./cmd/server) & \ + pnpm dev:web & \ + wait + +# Stop all services +stop: + @echo "Stopping services..." + @-lsof -ti:8080 | xargs kill -9 2>/dev/null + @-lsof -ti:3000 | xargs kill -9 2>/dev/null + docker compose down + @echo "✓ All services stopped." + +# ---------- Individual commands ---------- # Go server dev: diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx new file mode 100644 index 00000000..cb49d361 --- /dev/null +++ b/apps/web/app/(auth)/login/page.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Mock next/navigation +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => "/login", +})); + +// Mock auth-context +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("../../../lib/auth-context", () => ({ + useAuth: () => mockAuthValue, + AuthProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +import LoginPage from "./page"; + +describe("LoginPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders login form with heading, inputs, and button", () => { + render(); + + 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(); + }); + + 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); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai"); + await user.type(screen.getByPlaceholderText("Name"), "Test User"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User"); + }); + }); + + it("calls login with email only when name is empty", async () => { + mockLogin.mockResolvedValueOnce(undefined); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + await waitFor(() => { + 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.click(screen.getByRole("button", { name: "Sign in" })); + + await waitFor(() => { + expect(screen.getByText("Signing in...")).toBeInTheDocument(); + }); + }); + + it("shows error when login fails", async () => { + mockLogin.mockRejectedValueOnce(new Error("Network error")); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + + await waitFor(() => { + expect( + screen.getByText("Login failed. Make sure the server is running."), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index a30c021e..49c50fc5 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,15 +1,65 @@ "use client"; +import { useState } from "react"; +import { useAuth } from "../../../lib/auth-context"; + export default function LoginPage() { + const { login, isLoading } = useAuth(); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) { + setError("Email is required"); + return; + } + setError(""); + setSubmitting(true); + try { + await login(email, name || undefined); + } catch (err) { + setError("Login failed. Make sure the server is running."); + setSubmitting(false); + } + }; + return (
-
+

Multica

AI-native task management

- -
+
); } diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 49c7e355..7ebb0c00 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -1,217 +1,16 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Bot, Cloud, Monitor, Plus, - Wrench, - Blocks, Zap, - GitBranch, - FileCode, - MessageSquare, - Terminal, - Database, - Globe, ListTodo, } from "lucide-react"; -import type { AgentStatus, AgentRuntimeMode } from "@multica/types"; - -// --------------------------------------------------------------------------- -// Types for mock data -// --------------------------------------------------------------------------- - -interface AgentSkill { - id: string; - name: string; - description: string; -} - -interface AgentTool { - id: string; - name: string; - icon: typeof Terminal; - connected: boolean; -} - -interface AgentTask { - id: string; - issueKey: string; - title: string; - status: "working" | "queued"; -} - -interface MockAgent { - id: string; - name: string; - avatar: string; - runtimeMode: AgentRuntimeMode; - status: AgentStatus; - model: string; - description: string; - maxConcurrentTasks: number; - host?: string; - skills: AgentSkill[]; - tools: AgentTool[]; - currentTasks: AgentTask[]; - completedTasks: number; - createdAt: string; -} - -// --------------------------------------------------------------------------- -// Mock data -// --------------------------------------------------------------------------- - -const MOCK_AGENTS: MockAgent[] = [ - { - id: "agent_1", - name: "Claude-1", - avatar: "C1", - runtimeMode: "local", - status: "working", - model: "Claude Sonnet 4", - description: - "General-purpose coding agent for backend development. Specializes in Go API development, database migrations, and test writing.", - maxConcurrentTasks: 2, - host: "jiayuan-macbook", - skills: [ - { - id: "sk_1", - name: "Go API Development", - description: "Build RESTful APIs with Chi, implement CRUD handlers, add middleware", - }, - { - id: "sk_2", - name: "Database Migrations", - description: "Create and run PostgreSQL migrations, update sqlc queries", - }, - { - id: "sk_3", - name: "Test Writing", - description: "Write Go unit and integration tests with testcontainers", - }, - ], - tools: [ - { id: "t_1", name: "GitHub", icon: GitBranch, connected: true }, - { id: "t_2", name: "Terminal", icon: Terminal, connected: true }, - { id: "t_3", name: "PostgreSQL", icon: Database, connected: true }, - { id: "t_4", name: "Browser", icon: Globe, connected: false }, - ], - currentTasks: [ - { - id: "iss_9", - issueKey: "MUL-9", - title: "Implement issue list API endpoint", - status: "working", - }, - { - id: "iss_14", - issueKey: "MUL-14", - title: "Add WebSocket event types for agent status", - status: "queued", - }, - ], - completedTasks: 12, - createdAt: "2026-03-15T10:00:00Z", - }, - { - id: "agent_2", - name: "Codex-1", - avatar: "CX", - runtimeMode: "cloud", - status: "idle", - model: "GPT-5.3 Codex", - description: - "Cloud-hosted coding agent optimized for frontend development. Handles React components, styling, and TypeScript refactoring.", - maxConcurrentTasks: 4, - skills: [ - { - id: "sk_4", - name: "React Components", - description: "Build UI components with React, Radix UI, and Tailwind CSS", - }, - { - id: "sk_5", - name: "TypeScript Refactoring", - description: "Refactor code for type safety, extract shared types and utilities", - }, - ], - tools: [ - { id: "t_5", name: "GitHub", icon: GitBranch, connected: true }, - { id: "t_6", name: "Terminal", icon: Terminal, connected: true }, - { id: "t_7", name: "Browser", icon: Globe, connected: true }, - { id: "t_8", name: "Figma", icon: FileCode, connected: false }, - ], - currentTasks: [], - completedTasks: 8, - createdAt: "2026-03-16T14:00:00Z", - }, - { - id: "agent_3", - name: "Review Bot", - avatar: "RB", - runtimeMode: "cloud", - status: "working", - model: "Claude Sonnet 4", - description: - "Automated code reviewer. Analyzes PRs for correctness, security issues, and adherence to team coding standards.", - maxConcurrentTasks: 8, - skills: [ - { - id: "sk_6", - name: "Code Review", - description: "Review pull requests for bugs, security issues, and style violations", - }, - { - id: "sk_7", - name: "Security Audit", - description: "Check for OWASP top 10 vulnerabilities and insecure patterns", - }, - ], - tools: [ - { id: "t_9", name: "GitHub", icon: GitBranch, connected: true }, - { id: "t_10", name: "Comments", icon: MessageSquare, connected: true }, - ], - currentTasks: [ - { - id: "iss_pr47", - issueKey: "PR-47", - title: "Review: Add WebSocket reconnection logic", - status: "working", - }, - ], - completedTasks: 34, - createdAt: "2026-03-14T09:00:00Z", - }, - { - id: "agent_4", - name: "Claude-2", - avatar: "C2", - runtimeMode: "local", - status: "offline", - model: "Claude Sonnet 4", - description: - "Secondary local agent on Bohan's machine. Used for documentation and knowledge base tasks.", - maxConcurrentTasks: 1, - host: "bohan-macbook", - skills: [ - { - id: "sk_8", - name: "Documentation", - description: "Write and update technical docs, API references, and README files", - }, - ], - tools: [ - { id: "t_11", name: "GitHub", icon: GitBranch, connected: true }, - { id: "t_12", name: "Terminal", icon: Terminal, connected: true }, - ], - currentTasks: [], - completedTasks: 5, - createdAt: "2026-03-18T16:00:00Z", - }, -]; +import type { Agent, AgentStatus } from "@multica/types"; +import { api } from "../../../lib/api"; // --------------------------------------------------------------------------- // Helpers @@ -225,6 +24,15 @@ const statusConfig: Record w[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} + // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- @@ -234,7 +42,7 @@ function AgentListItem({ isSelected, onClick, }: { - agent: MockAgent; + agent: Agent; isSelected: boolean; onClick: () => void; }) { @@ -247,15 +55,14 @@ function AgentListItem({ isSelected ? "bg-accent" : "hover:bg-accent/50" }`} > - {/* Avatar */}
- {agent.avatar} + {getInitials(agent.name)}
{agent.name} - {agent.runtimeMode === "cloud" ? ( + {agent.runtime_mode === "cloud" ? ( ) : ( @@ -264,38 +71,13 @@ function AgentListItem({
{st.label} - {agent.currentTasks.length > 0 && ( - - · {agent.currentTasks.length} task{agent.currentTasks.length > 1 ? "s" : ""} - - )}
); } -function SectionHeader({ - icon: Icon, - title, - count, -}: { - icon: typeof Wrench; - title: string; - count?: number; -}) { - return ( -
- -

{title}

- {count !== undefined && ( - ({count}) - )} -
- ); -} - -function AgentDetail({ agent }: { agent: MockAgent }) { +function AgentDetail({ agent }: { agent: Agent }) { const st = statusConfig[agent.status]; return ( @@ -303,7 +85,7 @@ function AgentDetail({ agent }: { agent: MockAgent }) { {/* Header */}
- {agent.avatar} + {getInitials(agent.name)}
@@ -313,7 +95,9 @@ function AgentDetail({ agent }: { agent: MockAgent }) { {st.label}
-

{agent.description}

+

+ {agent.runtime_mode === "cloud" ? "Cloud-hosted" : "Local"} agent +

@@ -322,101 +106,53 @@ function AgentDetail({ agent }: { agent: MockAgent }) {
Runtime
- {agent.runtimeMode === "cloud" ? ( + {agent.runtime_mode === "cloud" ? ( ) : ( )} - {agent.runtimeMode === "cloud" ? "Cloud" : "Local"} - {agent.host && ( - ({agent.host}) - )} + {agent.runtime_mode === "cloud" ? "Cloud" : "Local"}
-
Model
-
{agent.model}
+
Visibility
+
{agent.visibility}
-
Concurrency
+
Max Concurrent Tasks
+
{agent.max_concurrent_tasks}
+
+
+
Created
- {agent.currentTasks.filter((t) => t.status === "working").length} / {agent.maxConcurrentTasks} slots + {new Date(agent.created_at).toLocaleDateString()}
-
-
Completed Tasks
-
{agent.completedTasks}
-
- {/* Skills */} + {/* Status */}
- -
- {agent.skills.map((skill) => ( -
-
{skill.name}
-
- {skill.description} -
-
- ))} +
+ +

Status

-
- - {/* Connected Tools */} -
- -
- {agent.tools.map((tool) => ( -
- - {tool.name} - {tool.connected ? ( - Connected - ) : ( - Not set up - )} -
- ))} -
-
- - {/* Current Tasks */} -
- - {agent.currentTasks.length > 0 ? ( -
- {agent.currentTasks.map((task) => ( -
- - {task.issueKey} - - {task.title} - - {task.status === "working" ? "Working" : "Queued"} - -
- ))} +
+
+ + {st.label}
- ) : ( -

No active tasks

- )} +
+
+ + {/* Tasks placeholder */} +
+
+ +

Tasks

+
+

+ Task queue will be shown here when agents are assigned issues. +

); @@ -427,8 +163,30 @@ function AgentDetail({ agent }: { agent: MockAgent }) { // --------------------------------------------------------------------------- export default function AgentsPage() { - const [selectedId, setSelectedId] = useState(MOCK_AGENTS[0]?.id ?? ""); - const selected = MOCK_AGENTS.find((a) => a.id === selectedId) ?? null; + const [agents, setAgents] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api + .listAgents() + .then((a) => { + setAgents(a); + if (a.length > 0) setSelectedId(a[0]!.id); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + const selected = agents.find((a) => a.id === selectedId) ?? null; + + if (loading) { + return ( +
+ Loading... +
+ ); + } return (
@@ -441,7 +199,7 @@ export default function AgentsPage() {
- {MOCK_AGENTS.map((agent) => ( + {agents.map((agent) => ( void; }) { - const Icon = typeIcons[item.type]; + const Icon = typeIcons[item.type] ?? CircleDot; const colorClass = severityColors[item.severity]; return (
- {item.type === "agent_blocked" || item.type === "review_requested" ? ( + {(item.type === "agent_blocked" || item.type === "review_requested") && (
Agent action
- ) : null} + )}
{!item.read && ( @@ -199,8 +93,14 @@ function InboxListItem({ ); } -function InboxDetail({ item }: { item: InboxItem }) { - const Icon = typeIcons[item.type]; +function InboxDetail({ + item, + onMarkRead, +}: { + item: InboxItem; + onMarkRead: (id: string) => void; +}) { + const Icon = typeIcons[item.type] ?? CircleDot; const colorClass = severityColors[item.severity]; const severityLabel: Record = { @@ -220,14 +120,16 @@ function InboxDetail({ item }: { item: InboxItem }) { {severityLabel[item.severity]} · {timeAgo(item.created_at)} - {item.issue_id && ( - <> - · - {item.issue_id} - - )} + {!item.read && ( + + )} {/* Body */} @@ -245,14 +147,47 @@ function InboxDetail({ item }: { item: InboxItem }) { // --------------------------------------------------------------------------- export default function InboxPage() { - const sorted = [...MOCK_INBOX_ITEMS].sort( - (a, b) => - severityOrder[a.severity] - severityOrder[b.severity] || - new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ); + const [items, setItems] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [loading, setLoading] = useState(true); - const [selectedId, setSelectedId] = useState(sorted[0]?.id ?? ""); - const selected = sorted.find((i) => i.id === selectedId) ?? null; + useEffect(() => { + api + .listInbox() + .then((data) => { + const sorted = [...data].sort( + (a, b) => + severityOrder[a.severity] - severityOrder[b.severity] || + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + setItems(sorted); + if (sorted.length > 0) setSelectedId(sorted[0]!.id); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + const handleMarkRead = async (id: string) => { + try { + await api.markInboxRead(id); + setItems((prev) => + prev.map((i) => (i.id === id ? { ...i, read: true } : i)) + ); + } catch (err) { + console.error("Failed to mark read:", err); + } + }; + + const selected = items.find((i) => i.id === selectedId) ?? null; + const unreadCount = items.filter((i) => !i.read).length; + + if (loading) { + return ( +
+ Loading... +
+ ); + } return (
@@ -260,29 +195,39 @@ export default function InboxPage() {

Inbox

- - {sorted.filter((i) => !i.read).length} - -
-
- {sorted.map((item) => ( - setSelectedId(item.id)} - /> - ))} + {unreadCount > 0 && ( + + {unreadCount} + + )}
+ {items.length === 0 ? ( +
+

No notifications yet

+
+ ) : ( +
+ {items.map((item) => ( + setSelectedId(item.id)} + /> + ))} +
+ )}
{/* Right column — detail */}
{selected ? ( - + ) : (
- Select an item to view details + {items.length === 0 + ? "Your inbox is empty" + : "Select an item to view details"}
)}
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx new file mode 100644 index 00000000..e2c13db9 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -0,0 +1,246 @@ +import { Suspense } from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Issue, Comment } from "@multica/types"; + +// Mock next/navigation +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => "/issues/issue-1", +})); + +// Mock next/link +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...props + }: { + children: React.ReactNode; + href: string; + [key: string]: any; + }) => ( + + {children} + + ), +})); + +// 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, + getActorName: (type: string, id: string) => { + if (type === "member" && id === "user-1") return "Test User"; + if (type === "agent" && id === "agent-1") return "Claude Agent"; + return "Unknown"; + }, + getActorInitials: (type: string, id: string) => { + if (type === "member") return "TU"; + if (type === "agent") return "CA"; + return "??"; + }, + }), +})); + +// Mock api +const mockGetIssue = vi.hoisted(() => vi.fn()); +const mockListComments = vi.hoisted(() => vi.fn()); +const mockCreateComment = vi.hoisted(() => vi.fn()); + +vi.mock("../../../../lib/api", () => ({ + api: { + getIssue: (...args: any[]) => mockGetIssue(...args), + listComments: (...args: any[]) => mockListComments(...args), + createComment: (...args: any[]) => mockCreateComment(...args), + }, +})); + +const mockIssue: Issue = { + id: "issue-1", + workspace_id: "ws-1", + title: "Implement authentication", + description: "Add JWT auth to the backend", + status: "in_progress", + priority: "high", + assignee_type: "member", + assignee_id: "user-1", + creator_type: "member", + creator_id: "user-1", + parent_issue_id: null, + acceptance_criteria: [], + context_refs: [], + repository: null, + position: 0, + due_date: "2026-06-01T00:00:00Z", + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-20T00:00:00Z", +}; + +const mockComments: Comment[] = [ + { + id: "comment-1", + issue_id: "issue-1", + content: "Started working on this", + type: "comment", + author_type: "member", + author_id: "user-1", + created_at: "2026-01-16T00:00:00Z", + updated_at: "2026-01-16T00:00:00Z", + }, + { + id: "comment-2", + issue_id: "issue-1", + content: "I can help with this", + type: "comment", + author_type: "agent", + author_id: "agent-1", + created_at: "2026-01-17T00:00:00Z", + updated_at: "2026-01-17T00:00:00Z", + }, +]; + +import IssueDetailPage from "./page"; + +// React 19 use(Promise) needs the promise to resolve within act + Suspense +async function renderPage(id = "issue-1") { + let result: ReturnType; + await act(async () => { + result = render( + Suspense loading...
}> + + , + ); + }); + return result!; +} + +describe("IssueDetailPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders issue details after loading", async () => { + mockGetIssue.mockResolvedValueOnce(mockIssue); + mockListComments.mockResolvedValueOnce(mockComments); + await renderPage(); + + await waitFor(() => { + expect( + screen.getByText("Implement authentication"), + ).toBeInTheDocument(); + }); + + expect( + screen.getByText("Add JWT auth to the backend"), + ).toBeInTheDocument(); + }); + + it("renders issue properties sidebar", async () => { + mockGetIssue.mockResolvedValueOnce(mockIssue); + mockListComments.mockResolvedValueOnce(mockComments); + await renderPage(); + + await waitFor(() => { + expect(screen.getByText("Properties")).toBeInTheDocument(); + }); + + expect(screen.getByText("In Progress")).toBeInTheDocument(); + expect(screen.getByText("High")).toBeInTheDocument(); + }); + + it("renders comments", async () => { + mockGetIssue.mockResolvedValueOnce(mockIssue); + mockListComments.mockResolvedValueOnce(mockComments); + await renderPage(); + + await waitFor(() => { + expect( + screen.getByText("Started working on this"), + ).toBeInTheDocument(); + }); + + expect(screen.getByText("I can help with this")).toBeInTheDocument(); + expect(screen.getByText("Activity")).toBeInTheDocument(); + }); + + it("shows 'Issue not found' for missing issue", async () => { + mockGetIssue.mockRejectedValueOnce(new Error("Not found")); + mockListComments.mockRejectedValueOnce(new Error("Not found")); + await renderPage("nonexistent-id"); + + await waitFor(() => { + expect(screen.getByText("Issue not found")).toBeInTheDocument(); + }); + }); + + it("submits a new comment", async () => { + mockGetIssue.mockResolvedValueOnce(mockIssue); + mockListComments.mockResolvedValueOnce(mockComments); + + const newComment: Comment = { + id: "comment-3", + issue_id: "issue-1", + content: "New test comment", + type: "comment", + author_type: "member", + author_id: "user-1", + created_at: "2026-01-18T00:00:00Z", + updated_at: "2026-01-18T00:00:00Z", + }; + mockCreateComment.mockResolvedValueOnce(newComment); + + const user = userEvent.setup(); + await renderPage(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText("Leave a comment..."), + ).toBeInTheDocument(); + }); + + await user.type( + screen.getByPlaceholderText("Leave a comment..."), + "New test comment", + ); + + const form = screen + .getByPlaceholderText("Leave a comment...") + .closest("form")!; + const submitBtn = form.querySelector( + 'button[type="submit"]', + ) as HTMLElement; + await user.click(submitBtn); + + await waitFor(() => { + expect(mockCreateComment).toHaveBeenCalledWith( + "issue-1", + "New test comment", + ); + }); + + await waitFor(() => { + expect(screen.getByText("New test comment")).toBeInTheDocument(); + }); + }); + + it("renders breadcrumb navigation", async () => { + mockGetIssue.mockResolvedValueOnce(mockIssue); + mockListComments.mockResolvedValueOnce(mockComments); + await renderPage(); + + await waitFor(() => { + expect(screen.getByText("Issues")).toBeInTheDocument(); + }); + + const issuesLink = screen.getByText("Issues"); + expect(issuesLink.closest("a")).toHaveAttribute("href", "/issues"); + }); +}); diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index 9be02a75..9c70d5ac 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -1,21 +1,17 @@ "use client"; -import { use } from "react"; +import { use, useState, useEffect } from "react"; import Link from "next/link"; import { Bot, - Calendar, ChevronRight, - User, - MessageSquare, + Send, } from "lucide-react"; -import { - MOCK_ISSUES, - STATUS_CONFIG, - PRIORITY_CONFIG, -} from "../_data/mock"; -import type { MockAssignee } from "../_data/mock"; +import type { Issue, Comment } from "@multica/types"; +import { STATUS_CONFIG, PRIORITY_CONFIG } from "../_data/mock"; import { StatusIcon, PriorityIcon } from "../page"; +import { api } from "../../../../lib/api"; +import { useAuth } from "../../../../lib/auth-context"; // --------------------------------------------------------------------------- // Helpers @@ -32,16 +28,8 @@ function timeAgo(dateStr: string): string { return `${days}d ago`; } -function formatDate(date: string | null): string { +function shortDate(date: string | null): string { if (!date) return "—"; - return new Date(date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); -} - -function shortDate(date: string): string { return new Date(date).toLocaleDateString("en-US", { month: "short", day: "numeric", @@ -52,14 +40,19 @@ function shortDate(date: string): string { // Avatar // --------------------------------------------------------------------------- -function Avatar({ - person, +function ActorAvatar({ + actorType, + actorId, size = 20, }: { - person: MockAssignee; + actorType: string; + actorId: string; size?: number; }) { - const isAgent = person.type === "agent"; + const { getActorName, getActorInitials } = useAuth(); + const name = getActorName(actorType, actorId); + const initials = getActorInitials(actorType, actorId); + const isAgent = actorType === "agent"; return (
- {isAgent ? : person.avatar.charAt(0)} + {isAgent ? ( + + ) : ( + initials + )}
); } // --------------------------------------------------------------------------- -// Property row (Linear-style: label left, clickable value right) +// Property row // --------------------------------------------------------------------------- function PropRow({ @@ -106,7 +103,45 @@ export default function IssueDetailPage({ params: Promise<{ id: string }>; }) { const { id } = use(params); - const issue = MOCK_ISSUES.find((i) => i.id === id); + const { getActorName } = useAuth(); + const [issue, setIssue] = useState(null); + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [commentText, setCommentText] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + Promise.all([api.getIssue(id), api.listComments(id)]) + .then(([iss, cmts]) => { + setIssue(iss); + setComments(cmts); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, [id]); + + const handleSubmitComment = async (e: React.FormEvent) => { + e.preventDefault(); + if (!commentText.trim() || submitting) return; + setSubmitting(true); + try { + const comment = await api.createComment(id, commentText.trim()); + setComments((prev) => [...prev, comment]); + setCommentText(""); + } catch (err) { + console.error("Failed to create comment:", err); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return ( +
+ Loading... +
+ ); + } if (!issue) { return ( @@ -119,31 +154,11 @@ export default function IssueDetailPage({ const statusCfg = STATUS_CONFIG[issue.status]; const priorityCfg = PRIORITY_CONFIG[issue.priority]; const isOverdue = - issue.dueDate && new Date(issue.dueDate) < new Date() && issue.status !== "done"; - - // Merge activity + comments into timeline - const timeline = [ - ...issue.activity.map((a) => ({ - id: a.id, - kind: "activity" as const, - actor: a.actor, - content: a.action, - createdAt: a.createdAt, - })), - ...issue.comments.map((c) => ({ - id: c.id, - kind: "comment" as const, - actor: c.author, - content: c.body, - createdAt: c.createdAt, - })), - ].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + issue.due_date && new Date(issue.due_date) < new Date() && issue.status !== "done"; return (
- {/* ================================================================ - LEFT: Content area - ================================================================ */} + {/* LEFT: Content area */}
{/* Header bar */}
@@ -154,90 +169,76 @@ export default function IssueDetailPage({ Issues - {issue.key} + {issue.id.slice(0, 8)}
{/* Content */}
- {/* Issue key */} -
{issue.key}
+
{issue.id.slice(0, 8)}
- {/* Title */}

{issue.title}

- {/* Description */} {issue.description && (
{issue.description}
)} - {/* Separator */}
- {/* Activity */} + {/* Activity / Comments */}

Activity

- {timeline.map((entry, idx) => - entry.kind === "comment" ? ( - /* ---- Comment ---- */ -
-
- - - {entry.actor.name} - - - {timeAgo(entry.createdAt)} - -
-
- {entry.content} -
-
- ) : ( - /* ---- Status change ---- */ -
- - + {comments.map((comment) => ( +
+
+ + + {getActorName(comment.author_type, comment.author_id)} - - - {entry.actor.name} - {" "} - {entry.content} + + {timeAgo(comment.created_at)} - {timeAgo(entry.createdAt)}
- ) - )} +
+ {comment.content} +
+
+ ))}
{/* Comment input */} -
-
- - - - - Leave a comment... - +
+
+ setCommentText(e.target.value)} + placeholder="Leave a comment..." + className="flex-1 rounded-md border bg-background px-3 py-2 text-[13px] placeholder:text-muted-foreground" + /> +
-
+
- {/* ================================================================ - RIGHT: Properties sidebar - ================================================================ */} + {/* RIGHT: Properties sidebar */}
@@ -256,10 +257,14 @@ export default function IssueDetailPage({ - {issue.assignee ? ( + {issue.assignee_type && issue.assignee_id ? ( <> - - {issue.assignee.name} + + {getActorName(issue.assignee_type, issue.assignee_id)} ) : ( Unassigned @@ -267,9 +272,9 @@ export default function IssueDetailPage({ - {issue.dueDate ? ( + {issue.due_date ? ( - {shortDate(issue.dueDate)} + {shortDate(issue.due_date)} ) : ( None @@ -277,17 +282,21 @@ export default function IssueDetailPage({ - - {issue.creator.name} + + {getActorName(issue.creator_type, issue.creator_id)}
- {shortDate(issue.createdAt)} + {shortDate(issue.created_at)} - {shortDate(issue.updatedAt)} + {shortDate(issue.updated_at)}
diff --git a/apps/web/app/(dashboard)/issues/_data/mock.ts b/apps/web/app/(dashboard)/issues/_data/mock.ts index d746aee6..082a0ea0 100644 --- a/apps/web/app/(dashboard)/issues/_data/mock.ts +++ b/apps/web/app/(dashboard)/issues/_data/mock.ts @@ -95,7 +95,12 @@ export const PRIORITY_CONFIG: Record< // Mock Issues // --------------------------------------------------------------------------- -const { jiayuan, bohan, yuzhen, claude1, codex1, reviewBot } = PEOPLE; +const jiayuan = PEOPLE["jiayuan"]!; +const bohan = PEOPLE["bohan"]!; +const yuzhen = PEOPLE["yuzhen"]!; +const claude1 = PEOPLE["claude1"]!; +const codex1 = PEOPLE["codex1"]!; +const reviewBot = PEOPLE["reviewBot"]!; export const MOCK_ISSUES: MockIssue[] = [ // ---- Backlog ---- diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx new file mode 100644 index 00000000..b9ca6aeb --- /dev/null +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -0,0 +1,302 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Issue, ListIssuesResponse } from "@multica/types"; + +// Mock next/navigation +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => "/issues", +})); + +// Mock next/link +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...props + }: { + children: React.ReactNode; + href: string; + [key: string]: any; + }) => ( + + {children} + + ), +})); + +// 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, + getActorName: (type: string, id: string) => + type === "member" ? "Test User" : "Claude Agent", + getActorInitials: () => "TU", + }), +})); + +// Mock WebSocket context +vi.mock("../../../lib/ws-context", () => ({ + useWSEvent: vi.fn(), + useWS: () => ({ subscribe: vi.fn(() => () => {}) }), + WSProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +// Mock api +const mockListIssues = vi.fn(); +const mockCreateIssue = vi.fn(); +const mockUpdateIssue = vi.fn(); + +vi.mock("../../../lib/api", () => ({ + api: { + listIssues: (...args: any[]) => mockListIssues(...args), + createIssue: (...args: any[]) => mockCreateIssue(...args), + updateIssue: (...args: any[]) => mockUpdateIssue(...args), + }, +})); + +const issueDefaults = { + parent_issue_id: null, + acceptance_criteria: [], + context_refs: [], + repository: null, + position: 0, +}; + +const mockIssues: Issue[] = [ + { + ...issueDefaults, + id: "issue-1", + workspace_id: "ws-1", + title: "Implement auth", + description: "Add JWT authentication", + status: "todo", + priority: "high", + assignee_type: "member", + assignee_id: "user-1", + creator_type: "member", + creator_id: "user-1", + due_date: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + { + ...issueDefaults, + id: "issue-2", + workspace_id: "ws-1", + title: "Design landing page", + description: null, + status: "in_progress", + priority: "medium", + assignee_type: "agent", + assignee_id: "agent-1", + creator_type: "member", + creator_id: "user-1", + due_date: "2026-02-01T00:00:00Z", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + { + ...issueDefaults, + id: "issue-3", + workspace_id: "ws-1", + title: "Write tests", + description: null, + status: "backlog", + priority: "low", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + due_date: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, +]; + +import IssuesPage from "./page"; + +describe("IssuesPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows loading state initially", () => { + mockListIssues.mockReturnValueOnce(new Promise(() => {})); + render(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("renders issues in board view after loading", async () => { + mockListIssues.mockResolvedValueOnce({ + issues: mockIssues, + total: 3, + } as ListIssuesResponse); + + render(); + + await waitFor(() => { + expect(screen.getByText("Implement auth")).toBeInTheDocument(); + }); + + expect(screen.getByText("Design landing page")).toBeInTheDocument(); + expect(screen.getByText("Write tests")).toBeInTheDocument(); + expect(screen.getByText("All Issues")).toBeInTheDocument(); + }); + + it("renders board columns", async () => { + mockListIssues.mockResolvedValueOnce({ + issues: mockIssues, + total: 3, + } as ListIssuesResponse); + + render(); + + await waitFor(() => { + expect(screen.getByText("Backlog")).toBeInTheDocument(); + }); + + expect(screen.getByText("Todo")).toBeInTheDocument(); + expect(screen.getByText("In Progress")).toBeInTheDocument(); + expect(screen.getByText("In Review")).toBeInTheDocument(); + expect(screen.getByText("Done")).toBeInTheDocument(); + }); + + it("switches to list view", async () => { + mockListIssues.mockResolvedValueOnce({ + issues: mockIssues, + total: 3, + } as ListIssuesResponse); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("Implement auth")).toBeInTheDocument(); + }); + + // Find the List button and click it + const listButton = screen.getByText("List"); + await user.click(listButton); + + // Issues should still be visible + expect(screen.getByText("Implement auth")).toBeInTheDocument(); + expect(screen.getByText("Design landing page")).toBeInTheDocument(); + }); + + it("shows 'New Issue' button and opens create form", async () => { + mockListIssues.mockResolvedValueOnce({ + issues: [], + total: 0, + } as ListIssuesResponse); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("New Issue")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("New Issue")); + + // Create form should be visible + expect( + screen.getByPlaceholderText("Issue title..."), + ).toBeInTheDocument(); + expect(screen.getByText("Create")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("creates an issue via the form", async () => { + mockListIssues.mockResolvedValueOnce({ + issues: [], + total: 0, + } as ListIssuesResponse); + + const newIssue: Issue = { + ...issueDefaults, + id: "issue-new", + workspace_id: "ws-1", + title: "New test issue", + description: null, + status: "backlog", + priority: "none", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + due_date: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; + mockCreateIssue.mockResolvedValueOnce(newIssue); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("New Issue")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("New Issue")); + await user.type( + screen.getByPlaceholderText("Issue title..."), + "New test issue", + ); + await user.click(screen.getByText("Create")); + + await waitFor(() => { + expect(mockCreateIssue).toHaveBeenCalledWith({ + title: "New test issue", + }); + }); + + // New issue should appear + await waitFor(() => { + expect(screen.getByText("New test issue")).toBeInTheDocument(); + }); + }); + + it("closes create form on Cancel", async () => { + mockListIssues.mockResolvedValueOnce({ + issues: [], + total: 0, + } as ListIssuesResponse); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("New Issue")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("New Issue")); + expect( + screen.getByPlaceholderText("Issue title..."), + ).toBeInTheDocument(); + + await user.click(screen.getByText("Cancel")); + expect( + screen.queryByPlaceholderText("Issue title..."), + ).not.toBeInTheDocument(); + expect(screen.getByText("New Issue")).toBeInTheDocument(); + }); + + it("handles API error gracefully", async () => { + mockListIssues.mockRejectedValueOnce(new Error("Network error")); + + render(); + + // Should finish loading without crashing + await waitFor(() => { + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx index a033d6f1..127b606a 100644 --- a/apps/web/app/(dashboard)/issues/page.tsx +++ b/apps/web/app/(dashboard)/issues/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import Link from "next/link"; import { Columns3, @@ -15,7 +15,6 @@ import { CircleAlert, Eye, Minus, - MessageSquare, } from "lucide-react"; import { DndContext, @@ -30,14 +29,12 @@ import { } from "@dnd-kit/core"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { IssueStatus, IssuePriority } from "@multica/types"; -import { - MOCK_ISSUES, - STATUS_CONFIG, - PRIORITY_CONFIG, - type MockIssue, - type MockAssignee, -} from "./_data/mock"; +import type { Issue, IssueStatus, IssuePriority } from "@multica/types"; +import { STATUS_CONFIG, PRIORITY_CONFIG } from "./_data/mock"; +import { api } from "../../../lib/api"; +import { useAuth } from "../../../lib/auth-context"; +import { useWSEvent } from "../../../lib/ws-context"; +import type { IssueCreatedPayload, IssueUpdatedPayload, IssueDeletedPayload } from "@multica/types"; // --------------------------------------------------------------------------- // Shared icon components @@ -98,27 +95,30 @@ export function PriorityIcon({ } function AssigneeAvatar({ - assignee, + issue, size = "sm", }: { - assignee: MockAssignee | null; + issue: Issue; size?: "sm" | "md"; }) { - if (!assignee) return null; + 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 (
- {assignee.type === "agent" ? ( + {issue.assignee_type === "agent" ? ( ) : ( - assignee.avatar.charAt(0) + initials )}
); @@ -132,30 +132,24 @@ function formatDate(date: string): string { } // --------------------------------------------------------------------------- -// Board View — Card (static, used in both draggable wrapper and overlay) +// Board View — Card // --------------------------------------------------------------------------- -function BoardCardContent({ issue }: { issue: MockIssue }) { +function BoardCardContent({ issue }: { issue: Issue }) { return (
- {issue.key} + {issue.id.slice(0, 8)}

{issue.title}

- - {issue.comments.length > 0 && ( - - - {issue.comments.length} - - )} +
- {issue.dueDate && ( + {issue.due_date && ( - {formatDate(issue.dueDate)} + {formatDate(issue.due_date)} )}
@@ -167,7 +161,7 @@ function BoardCardContent({ issue }: { issue: MockIssue }) { // Draggable card wrapper // --------------------------------------------------------------------------- -function DraggableBoardCard({ issue }: { issue: MockIssue }) { +function DraggableBoardCard({ issue }: { issue: Issue }) { const { attributes, listeners, @@ -196,7 +190,6 @@ function DraggableBoardCard({ issue }: { issue: MockIssue }) { { - // Prevent navigation when dragging if (isDragging) e.preventDefault(); }} className="block transition-colors hover:opacity-80" @@ -216,7 +209,7 @@ function DroppableColumn({ issues, }: { status: IssueStatus; - issues: MockIssue[]; + issues: Issue[]; }) { const cfg = STATUS_CONFIG[status]; const { setNodeRef, isOver } = useDroppable({ id: status }); @@ -250,10 +243,10 @@ function BoardView({ issues, onMoveIssue, }: { - issues: MockIssue[]; + issues: Issue[]; onMoveIssue: (issueId: string, newStatus: IssueStatus) => void; }) { - const [activeIssue, setActiveIssue] = useState(null); + const [activeIssue, setActiveIssue] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { @@ -284,14 +277,11 @@ function BoardView({ if (!over) return; const issueId = active.id as string; - // `over.id` is the column's droppable id (a status string) - // or another card's sortable id let targetStatus: IssueStatus | undefined; if (visibleStatuses.includes(over.id as IssueStatus)) { targetStatus = over.id as IssueStatus; } else { - // Dropped on a card — find which column that card is in const targetIssue = issues.find((i) => i.id === over.id); if (targetIssue) targetStatus = targetIssue.status; } @@ -338,27 +328,29 @@ function BoardView({ // List View // --------------------------------------------------------------------------- -function ListRow({ issue }: { issue: MockIssue }) { +function ListRow({ issue }: { issue: Issue }) { return ( - {issue.key} + + {issue.id.slice(0, 8)} + {issue.title} - {issue.dueDate && ( + {issue.due_date && ( - {formatDate(issue.dueDate)} + {formatDate(issue.due_date)} )} - + ); } -function ListView({ issues }: { issues: MockIssue[] }) { +function ListView({ issues }: { issues: Issue[] }) { const groupOrder: IssueStatus[] = [ "in_review", "in_progress", @@ -390,6 +382,69 @@ function ListView({ issues }: { issues: MockIssue[] }) { ); } +// --------------------------------------------------------------------------- +// Create Issue Dialog (simple inline) +// --------------------------------------------------------------------------- + +function CreateIssueForm({ onCreated }: { onCreated: (issue: Issue) => void }) { + const [title, setTitle] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + try { + const issue = await api.createIssue({ title: title.trim() }); + onCreated(issue); + setTitle(""); + setIsOpen(false); + } catch (err) { + console.error("Failed to create issue:", err); + } + }; + + if (!isOpen) { + return ( + + ); + } + + return ( +
+ setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") setIsOpen(false); + }} + placeholder="Issue title..." + className="rounded-md border bg-background px-2 py-1 text-xs w-48" + /> + + +
+ ); +} + // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -398,21 +453,78 @@ type ViewMode = "board" | "list"; export default function IssuesPage() { const [view, setView] = useState("board"); - const [issues, setIssues] = useState(MOCK_ISSUES); + const [issues, setIssues] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api + .listIssues({ limit: 200 }) + .then((res) => { + setIssues(res.issues); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + // Real-time updates + useWSEvent( + "issue:created", + useCallback((payload: unknown) => { + const { issue } = payload as IssueCreatedPayload; + setIssues((prev) => { + if (prev.some((i) => i.id === issue.id)) return prev; + return [...prev, issue]; + }); + }, []), + ); + + useWSEvent( + "issue:updated", + useCallback((payload: unknown) => { + const { issue } = payload as IssueUpdatedPayload; + setIssues((prev) => prev.map((i) => (i.id === issue.id ? issue : i))); + }, []), + ); + + useWSEvent( + "issue:deleted", + useCallback((payload: unknown) => { + const { issue_id } = payload as IssueDeletedPayload; + setIssues((prev) => prev.filter((i) => i.id !== issue_id)); + }, []), + ); const handleMoveIssue = useCallback( (issueId: string, newStatus: IssueStatus) => { + // Optimistic update setIssues((prev) => prev.map((issue) => - issue.id === issueId - ? { ...issue, status: newStatus, updatedAt: new Date().toISOString() } - : issue + issue.id === issueId ? { ...issue, status: newStatus } : issue ) ); + + // Persist to API + api.updateIssue(issueId, { status: newStatus }).catch((err) => { + console.error("Failed to update issue:", err); + // Revert on error + api.listIssues({ limit: 200 }).then((res) => setIssues(res.issues)); + }); }, [] ); + const handleIssueCreated = useCallback((issue: Issue) => { + setIssues((prev) => [...prev, issue]); + }, []); + + if (loading) { + return ( +
+ Loading... +
+ ); + } + return (
{/* Toolbar */} @@ -444,10 +556,7 @@ export default function IssuesPage() {
- +
diff --git a/apps/web/app/(dashboard)/knowledge-base/page.tsx b/apps/web/app/(dashboard)/knowledge-base/page.tsx index e02b2ea7..97e6fe8e 100644 --- a/apps/web/app/(dashboard)/knowledge-base/page.tsx +++ b/apps/web/app/(dashboard)/knowledge-base/page.tsx @@ -31,15 +31,15 @@ function renderMarkdown(text: string): React.ReactNode[] { let i = 0; while (i < lines.length) { - const line = lines[i]; + const line = lines[i]!; // Code block if (line.startsWith("```")) { const lang = line.slice(3).trim(); const codeLines: string[] = []; i++; - while (i < lines.length && !lines[i].startsWith("```")) { - codeLines.push(lines[i]); + while (i < lines.length && !lines[i]!.startsWith("```")) { + codeLines.push(lines[i]!); i++; } i++; // skip closing ``` @@ -57,8 +57,8 @@ function renderMarkdown(text: string): React.ReactNode[] { // Table (simplified: detect | pipes) if (line.includes("|") && line.trim().startsWith("|")) { const tableRows: string[] = []; - while (i < lines.length && lines[i].includes("|") && lines[i].trim().startsWith("|")) { - tableRows.push(lines[i]); + while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim().startsWith("|")) { + tableRows.push(lines[i]!); i++; } // Filter out separator rows (|---|---|) @@ -66,7 +66,7 @@ function renderMarkdown(text: string): React.ReactNode[] { if (dataRows.length > 0) { const parseRow = (row: string) => row.split("|").filter((c) => c.trim() !== "").map((c) => c.trim()); - const header = parseRow(dataRows[0]); + const header = parseRow(dataRows[0]!); const body = dataRows.slice(1).map(parseRow); elements.push(
@@ -103,7 +103,7 @@ function renderMarkdown(text: string): React.ReactNode[] { elements.push(

{line.slice(3)} -

+ , ); i++; continue; @@ -112,14 +112,14 @@ function renderMarkdown(text: string): React.ReactNode[] { elements.push(

{line.slice(4)} -

+ , ); i++; continue; } // List item - if (line.match(/^- \[[ x]\] /)) { + if (/^- \[[ x]\] /.test(line)) { const checked = line.includes("[x]"); const text = line.replace(/^- \[[ x]\] /, ""); elements.push( @@ -142,8 +142,8 @@ function renderMarkdown(text: string): React.ReactNode[] { continue; } // Numbered list - if (line.match(/^\d+\. /)) { - const num = line.match(/^(\d+)\. /)![1]; + if (/^\d+\. /.test(line)) { + const num = line.match(/^(\d+)\. /)![1]!; const text = line.replace(/^\d+\. /, ""); elements.push(
@@ -155,7 +155,7 @@ function renderMarkdown(text: string): React.ReactNode[] { continue; } - // Empty line + // Empty line — guard is redundant since line is already asserted, but keeps TS happy if (line.trim() === "") { elements.push(
); i++; diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index e20cf58b..cd03bcf4 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -1,15 +1,22 @@ "use client"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useState, useCallback } from "react"; import { Inbox, ListTodo, Bot, BookOpen, ChevronDown, + Settings, + LogOut, + Plus, } from "lucide-react"; import { MulticaIcon } from "@multica/ui/components/multica-icon"; +import { useAuth } from "../../lib/auth-context"; +import type { Workspace } from "@multica/types"; +import { api } from "../../lib/api"; const navItems = [ { href: "/inbox", label: "Inbox", icon: Inbox }, @@ -24,18 +31,75 @@ export default function DashboardLayout({ children: React.ReactNode; }) { const pathname = usePathname(); + const router = useRouter(); + const { user, workspace, isLoading, logout } = useAuth(); + const [showMenu, setShowMenu] = useState(false); + + useEffect(() => { + if (!isLoading && !user) { + router.push("/login"); + } + }, [user, isLoading, router]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!user) return null; return (
- {/* Sidebar — sits on the canvas layer */} + {/* Sidebar */} - {/* Main content — floating panel on top of the canvas */} + {/* Main content */}
{children} diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 1ee322b5..a8ce15d5 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -1,10 +1,155 @@ -export default function SettingsPage() { +"use client"; + +import { useState } from "react"; +import { Settings, Users, Building2, Save, Crown, Shield, User } from "lucide-react"; +import type { MemberWithUser, MemberRole } from "@multica/types"; +import { useAuth } from "../../../lib/auth-context"; +import { api } from "../../../lib/api"; + +const roleConfig: Record = { + owner: { label: "Owner", icon: Crown }, + admin: { label: "Admin", icon: Shield }, + member: { label: "Member", icon: User }, +}; + +function MemberRow({ member }: { member: MemberWithUser }) { + const rc = roleConfig[member.role]; + const RoleIcon = rc.icon; + return ( -
-

Settings

-

- Workspace settings coming soon. -

+
+
+ {member.name + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2)} +
+
+
{member.name}
+
{member.email}
+
+
+ + {rc.label} +
+
+ ); +} + +export default function SettingsPage() { + const { workspace, members } = useAuth(); + + const [name, setName] = useState(workspace?.name ?? ""); + const [description, setDescription] = useState( + workspace?.description ?? "", + ); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + const handleSave = async () => { + if (!workspace) return; + setSaving(true); + try { + await api.updateWorkspace(workspace.id, { + name, + description: description || undefined, + }); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } catch (e) { + console.error("Failed to update workspace", e); + } finally { + setSaving(false); + } + }; + + if (!workspace) return null; + + return ( +
+ {/* Page header */} +
+ +

Settings

+
+ + {/* Workspace info */} +
+
+ +

Workspace

+
+ +
+
+ + setName(e.target.value)} + 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" + /> +
+
+ +