- {/* Description */}
{issue.description && (
- {/* Activity */}
+ {/* Activity / Comments */}
- {/* ================================================================
- 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 (
+
+ );
+}
+
// ---------------------------------------------------------------------------
// 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() {
-
+