feat(web): integrate frontend with live API and add auth context

- Replace all mock data with real API calls across pages (issues, agents, inbox, settings)
- Add AuthProvider context with JWT login/logout, member/agent name resolution
- Implement login page with email-based auth flow
- Add settings page with workspace editing and member list
- Wire up real-time WebSocket for live issue updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-03-22 11:50:14 +08:00
parent 1e61c1974c
commit 78f4d88aa1
14 changed files with 1315 additions and 642 deletions

View file

@ -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 (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-sm space-y-6 text-center">
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4 text-center">
<h1 className="text-2xl font-bold">Multica</h1>
<p className="text-muted-foreground">AI-native task management</p>
<button className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground">
Sign in with Google
<div className="space-y-3 text-left">
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
type="submit"
disabled={submitting || isLoading}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
>
{submitting ? "Signing in..." : "Sign in"}
</button>
</div>
</form>
</div>
);
}

View file

@ -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<AgentStatus, { label: string; color: string; dot: str
offline: { label: "Offline", color: "text-muted-foreground/50", dot: "bg-muted-foreground/40" },
};
function getInitials(name: string): string {
return name
.split(/[\s-]+/)
.map((w) => 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 */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-semibold">
{agent.avatar}
{getInitials(agent.name)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{agent.name}</span>
{agent.runtimeMode === "cloud" ? (
{agent.runtime_mode === "cloud" ? (
<Cloud className="h-3 w-3 text-muted-foreground" />
) : (
<Monitor className="h-3 w-3 text-muted-foreground" />
@ -264,38 +71,13 @@ function AgentListItem({
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
<span className={`text-xs ${st.color}`}>{st.label}</span>
{agent.currentTasks.length > 0 && (
<span className="text-xs text-muted-foreground">
· {agent.currentTasks.length} task{agent.currentTasks.length > 1 ? "s" : ""}
</span>
)}
</div>
</div>
</button>
);
}
function SectionHeader({
icon: Icon,
title,
count,
}: {
icon: typeof Wrench;
title: string;
count?: number;
}) {
return (
<div className="flex items-center gap-2 mb-3">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{title}</h3>
{count !== undefined && (
<span className="text-xs text-muted-foreground">({count})</span>
)}
</div>
);
}
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 */}
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-muted text-sm font-bold">
{agent.avatar}
{getInitials(agent.name)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
@ -313,7 +95,9 @@ function AgentDetail({ agent }: { agent: MockAgent }) {
{st.label}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{agent.description}</p>
<p className="mt-1 text-sm text-muted-foreground">
{agent.runtime_mode === "cloud" ? "Cloud-hosted" : "Local"} agent
</p>
</div>
</div>
@ -322,101 +106,53 @@ function AgentDetail({ agent }: { agent: MockAgent }) {
<div>
<div className="text-xs text-muted-foreground">Runtime</div>
<div className="mt-1 flex items-center gap-1.5 text-sm font-medium">
{agent.runtimeMode === "cloud" ? (
{agent.runtime_mode === "cloud" ? (
<Cloud className="h-3.5 w-3.5" />
) : (
<Monitor className="h-3.5 w-3.5" />
)}
{agent.runtimeMode === "cloud" ? "Cloud" : "Local"}
{agent.host && (
<span className="text-muted-foreground font-normal">({agent.host})</span>
)}
{agent.runtime_mode === "cloud" ? "Cloud" : "Local"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Model</div>
<div className="mt-1 text-sm font-medium">{agent.model}</div>
<div className="text-xs text-muted-foreground">Visibility</div>
<div className="mt-1 text-sm font-medium capitalize">{agent.visibility}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Concurrency</div>
<div className="text-xs text-muted-foreground">Max Concurrent Tasks</div>
<div className="mt-1 text-sm font-medium">{agent.max_concurrent_tasks}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Created</div>
<div className="mt-1 text-sm font-medium">
{agent.currentTasks.filter((t) => t.status === "working").length} / {agent.maxConcurrentTasks} slots
{new Date(agent.created_at).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Completed Tasks</div>
<div className="mt-1 text-sm font-medium">{agent.completedTasks}</div>
</div>
</div>
{/* Skills */}
{/* Status */}
<div>
<SectionHeader icon={Zap} title="Skills" count={agent.skills.length} />
<div className="space-y-2">
{agent.skills.map((skill) => (
<div
key={skill.id}
className="rounded-lg border px-4 py-3"
>
<div className="text-sm font-medium">{skill.name}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{skill.description}
</div>
</div>
))}
<div className="flex items-center gap-2 mb-3">
<Zap className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Status</h3>
</div>
</div>
{/* Connected Tools */}
<div>
<SectionHeader icon={Blocks} title="Connected Tools" count={agent.tools.length} />
<div className="grid grid-cols-2 gap-2">
{agent.tools.map((tool) => (
<div
key={tool.id}
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
tool.connected ? "" : "opacity-50"
}`}
>
<tool.icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-sm">{tool.name}</span>
{tool.connected ? (
<span className="ml-auto text-xs text-green-600">Connected</span>
) : (
<span className="ml-auto text-xs text-muted-foreground">Not set up</span>
)}
</div>
))}
</div>
</div>
{/* Current Tasks */}
<div>
<SectionHeader icon={ListTodo} title="Current Tasks" count={agent.currentTasks.length} />
{agent.currentTasks.length > 0 ? (
<div className="space-y-2">
{agent.currentTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 rounded-lg border px-4 py-3"
>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-mono font-medium">
{task.issueKey}
</span>
<span className="min-w-0 flex-1 truncate text-sm">{task.title}</span>
<span
className={`shrink-0 text-xs ${
task.status === "working" ? "text-green-600" : "text-muted-foreground"
}`}
>
{task.status === "working" ? "Working" : "Queued"}
</span>
</div>
))}
<div className="rounded-lg border px-4 py-3">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${st.dot}`} />
<span className={`text-sm font-medium ${st.color}`}>{st.label}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">No active tasks</p>
)}
</div>
</div>
{/* Tasks placeholder */}
<div>
<div className="flex items-center gap-2 mb-3">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Tasks</h3>
</div>
<p className="text-sm text-muted-foreground">
Task queue will be shown here when agents are assigned issues.
</p>
</div>
</div>
);
@ -427,8 +163,30 @@ function AgentDetail({ agent }: { agent: MockAgent }) {
// ---------------------------------------------------------------------------
export default function AgentsPage() {
const [selectedId, setSelectedId] = useState<string>(MOCK_AGENTS[0]?.id ?? "");
const selected = MOCK_AGENTS.find((a) => a.id === selectedId) ?? null;
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
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 (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
return (
<div className="flex h-full">
@ -441,7 +199,7 @@ export default function AgentsPage() {
</button>
</div>
<div className="divide-y">
{MOCK_AGENTS.map((agent) => (
{agents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import {
AlertCircle,
Bot,
@ -11,111 +11,7 @@ import {
ArrowRightLeft,
} from "lucide-react";
import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types";
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const MOCK_INBOX_ITEMS: InboxItem[] = [
{
id: "inb_1",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "agent_blocked",
severity: "action_required",
issue_id: "iss_12",
title: "Agent Claude-1 is blocked on MUL-12",
body: "I need clarification on the authentication flow. The current OAuth implementation uses PKCE, but the design doc references a session-based approach. Which one should I follow?\n\nSpecifically:\n1. Should we keep the PKCE flow for the SPA?\n2. Is the session cookie approach only for the server-rendered pages?\n3. Should I implement both and let the client decide?\n\nBlocked on this decision before I can continue with the login page implementation.",
read: false,
archived: false,
created_at: "2026-03-21T05:32:00Z",
},
{
id: "inb_2",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "review_requested",
severity: "action_required",
issue_id: "iss_8",
title: "PR #47: Add WebSocket reconnection logic",
body: "Agent Codex-1 has submitted a pull request for review.\n\n**Changes:**\n- Added exponential backoff for WebSocket reconnection\n- Max retry attempts configurable via env var\n- Added connection state to the store\n- Unit tests for reconnection logic\n\n**Files changed:** 6 files (+284, -12)\n\nThe agent notes that it chose exponential backoff over linear retry because of the bursty reconnection pattern observed in the daemon logs.",
read: false,
archived: false,
created_at: "2026-03-21T04:15:00Z",
},
{
id: "inb_3",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "issue_assigned",
severity: "action_required",
issue_id: "iss_15",
title: "New issue assigned: Design the agent config UI",
body: "You've been assigned to MUL-15: Design the agent config UI.\n\nPriority: High\nCreated by: Bohan\n\nDescription:\nWe need a configuration panel where users can set up their local agents — select runtime type, set concurrency limits, and manage API keys. This should live in the Settings page for now.",
read: true,
archived: false,
created_at: "2026-03-21T02:40:00Z",
},
{
id: "inb_4",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "agent_completed",
severity: "attention",
issue_id: "iss_6",
title: "Agent Claude-1 completed MUL-6: API error handling",
body: "The task has been completed and all acceptance criteria passed:\n\n✅ Standardized error response format\n✅ Added error codes enum\n✅ Middleware catches panics and returns 500\n✅ All existing tests still pass\n✅ 4 new test cases added\n\nPR #45 has been created and CI is green. Ready for your review when convenient.",
read: false,
archived: false,
created_at: "2026-03-20T22:10:00Z",
},
{
id: "inb_5",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "mentioned",
severity: "attention",
issue_id: "iss_10",
title: "Yuzhen mentioned you in MUL-10",
body: "@jiayuan Can you take a look at the database schema for the knowledge base? I want to make sure the vector embeddings table is set up correctly before we start indexing.\n\nI'm thinking we should use pgvector with HNSW index for the similarity search. Thoughts?",
read: true,
archived: false,
created_at: "2026-03-20T18:30:00Z",
},
{
id: "inb_6",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "status_change",
severity: "info",
issue_id: "iss_3",
title: "MUL-3 moved to Done",
body: "Issue \"Set up CI/CD pipeline\" has been moved from In Review to Done by Bohan.\n\nThe GitHub Actions workflow is now running on every push to main. Build, test, and lint checks are all configured.",
read: true,
archived: false,
created_at: "2026-03-20T15:00:00Z",
},
{
id: "inb_7",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "status_change",
severity: "info",
issue_id: "iss_9",
title: "MUL-9 moved to In Progress",
body: "Agent Codex-1 has started working on \"Implement issue list API endpoint\".\n\nEstimated approach:\n1. Add sqlc queries for listing/filtering issues\n2. Implement Chi handler with pagination\n3. Add sorting by priority, status, created_at\n4. Write integration tests",
read: true,
archived: false,
created_at: "2026-03-20T12:45:00Z",
},
];
import { api } from "../../../lib/api";
// ---------------------------------------------------------------------------
// Helpers
@ -165,16 +61,14 @@ function InboxListItem({
isSelected: boolean;
onClick: () => void;
}) {
const Icon = typeIcons[item.type];
const Icon = typeIcons[item.type] ?? CircleDot;
const colorClass = severityColors[item.severity];
return (
<button
onClick={onClick}
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors ${
isSelected
? "bg-accent"
: "hover:bg-accent/50"
isSelected ? "bg-accent" : "hover:bg-accent/50"
} ${!item.read ? "font-medium" : ""}`}
>
<Icon className={`mt-0.5 h-4 w-4 shrink-0 ${colorClass}`} />
@ -185,12 +79,12 @@ function InboxListItem({
{timeAgo(item.created_at)}
</span>
</div>
{item.type === "agent_blocked" || item.type === "review_requested" ? (
{(item.type === "agent_blocked" || item.type === "review_requested") && (
<div className="mt-0.5 flex items-center gap-1.5">
<Bot className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Agent action</span>
</div>
) : null}
)}
</div>
{!item.read && (
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
@ -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<InboxSeverity, string> = {
@ -220,14 +120,16 @@ function InboxDetail({ item }: { item: InboxItem }) {
<span className={colorClass}>{severityLabel[item.severity]}</span>
<span>·</span>
<span>{timeAgo(item.created_at)}</span>
{item.issue_id && (
<>
<span>·</span>
<span>{item.issue_id}</span>
</>
)}
</div>
</div>
{!item.read && (
<button
onClick={() => onMarkRead(item.id)}
className="shrink-0 rounded-md border px-2 py-1 text-xs hover:bg-accent"
>
Mark read
</button>
)}
</div>
{/* 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<InboxItem[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string>(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 (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
return (
<div className="flex h-full">
@ -260,29 +195,39 @@ export default function InboxPage() {
<div className="w-80 shrink-0 overflow-y-auto border-r">
<div className="flex h-12 items-center border-b px-4">
<h1 className="text-sm font-semibold">Inbox</h1>
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
{sorted.filter((i) => !i.read).length}
</span>
</div>
<div className="divide-y">
{sorted.map((item) => (
<InboxListItem
key={item.id}
item={item}
isSelected={item.id === selectedId}
onClick={() => setSelectedId(item.id)}
/>
))}
{unreadCount > 0 && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
{unreadCount}
</span>
)}
</div>
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-sm text-muted-foreground">
<p>No notifications yet</p>
</div>
) : (
<div className="divide-y">
{items.map((item) => (
<InboxListItem
key={item.id}
item={item}
isSelected={item.id === selectedId}
onClick={() => setSelectedId(item.id)}
/>
))}
</div>
)}
</div>
{/* Right column — detail */}
<div className="flex-1 overflow-y-auto">
{selected ? (
<InboxDetail item={selected} />
<InboxDetail item={selected} onMarkRead={handleMarkRead} />
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
Select an item to view details
{items.length === 0
? "Your inbox is empty"
: "Select an item to view details"}
</div>
)}
</div>

View file

@ -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 (
<div
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium ${
@ -68,15 +61,19 @@ function Avatar({
: "bg-muted text-muted-foreground"
}`}
style={{ width: size, height: size, fontSize: size * 0.45 }}
title={person.name}
title={name}
>
{isAgent ? <Bot style={{ width: size * 0.55, height: size * 0.55 }} /> : person.avatar.charAt(0)}
{isAgent ? (
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
) : (
initials
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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<Issue | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
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 (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
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 (
<div className="flex h-full">
{/* ================================================================
LEFT: Content area
================================================================ */}
{/* LEFT: Content area */}
<div className="flex-1 overflow-y-auto">
{/* Header bar */}
<div className="sticky top-0 z-10 flex h-11 items-center gap-1.5 border-b bg-background px-6 text-[13px]">
@ -154,90 +169,76 @@ export default function IssueDetailPage({
Issues
</Link>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span className="truncate text-muted-foreground">{issue.key}</span>
<span className="truncate text-muted-foreground">{issue.id.slice(0, 8)}</span>
</div>
{/* Content */}
<div className="mx-auto w-full max-w-3xl px-8 py-8">
{/* Issue key */}
<div className="mb-1 text-[13px] text-muted-foreground">{issue.key}</div>
<div className="mb-1 text-[13px] text-muted-foreground">{issue.id.slice(0, 8)}</div>
{/* Title */}
<h1 className="text-xl font-semibold leading-snug tracking-tight">
{issue.title}
</h1>
{/* Description */}
{issue.description && (
<div className="mt-5 text-[14px] leading-[1.7] text-foreground/85 whitespace-pre-wrap">
{issue.description}
</div>
)}
{/* Separator */}
<div className="my-8 border-t" />
{/* Activity */}
{/* Activity / Comments */}
<div>
<h2 className="text-[13px] font-medium">Activity</h2>
<div className="mt-4">
{timeline.map((entry, idx) =>
entry.kind === "comment" ? (
/* ---- Comment ---- */
<div key={entry.id} className="relative py-3">
<div className="flex items-center gap-2.5">
<Avatar person={entry.actor} size={28} />
<span className="text-[13px] font-medium">
{entry.actor.name}
</span>
<span className="text-[12px] text-muted-foreground">
{timeAgo(entry.createdAt)}
</span>
</div>
<div className="mt-2 pl-[38px] text-[13px] leading-[1.6] text-foreground/85 whitespace-pre-wrap">
{entry.content}
</div>
</div>
) : (
/* ---- Status change ---- */
<div
key={entry.id}
className="flex items-center gap-2.5 py-1.5 text-[12px] text-muted-foreground"
>
<span className="flex h-[28px] w-[28px] shrink-0 items-center justify-center">
<span className="h-[5px] w-[5px] rounded-full bg-muted-foreground/40" />
{comments.map((comment) => (
<div key={comment.id} className="relative py-3">
<div className="flex items-center gap-2.5">
<ActorAvatar
actorType={comment.author_type}
actorId={comment.author_id}
size={28}
/>
<span className="text-[13px] font-medium">
{getActorName(comment.author_type, comment.author_id)}
</span>
<span>
<span className="text-foreground/70">
{entry.actor.name}
</span>{" "}
{entry.content}
<span className="text-[12px] text-muted-foreground">
{timeAgo(comment.created_at)}
</span>
<span className="ml-auto shrink-0">{timeAgo(entry.createdAt)}</span>
</div>
)
)}
<div className="mt-2 pl-[38px] text-[13px] leading-[1.6] text-foreground/85 whitespace-pre-wrap">
{comment.content}
</div>
</div>
))}
</div>
{/* Comment input */}
<div className="mt-2 border-t pt-4">
<div className="flex items-center gap-2.5 cursor-text text-[13px] text-muted-foreground">
<span className="flex h-[28px] w-[28px] shrink-0 items-center justify-center">
<span className="h-[5px] w-[5px] rounded-full bg-muted-foreground/30" />
</span>
<span className="transition-colors hover:text-foreground/50">
Leave a comment...
</span>
<form onSubmit={handleSubmitComment} className="mt-2 border-t pt-4">
<div className="flex items-center gap-2">
<input
type="text"
value={commentText}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={!commentText.trim() || submitting}
className="rounded-md bg-primary p-2 text-primary-foreground disabled:opacity-50"
>
<Send className="h-3.5 w-3.5" />
</button>
</div>
</div>
</form>
</div>
</div>
</div>
{/* ================================================================
RIGHT: Properties sidebar
================================================================ */}
{/* RIGHT: Properties sidebar */}
<div className="w-60 shrink-0 overflow-y-auto border-l">
<div className="p-4">
<div className="mb-2 text-[12px] font-medium text-muted-foreground">
@ -256,10 +257,14 @@ export default function IssueDetailPage({
</PropRow>
<PropRow label="Assignee">
{issue.assignee ? (
{issue.assignee_type && issue.assignee_id ? (
<>
<Avatar person={issue.assignee} size={18} />
<span>{issue.assignee.name}</span>
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={18}
/>
<span>{getActorName(issue.assignee_type, issue.assignee_id)}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
@ -267,9 +272,9 @@ export default function IssueDetailPage({
</PropRow>
<PropRow label="Due date">
{issue.dueDate ? (
{issue.due_date ? (
<span className={isOverdue ? "text-red-500" : ""}>
{shortDate(issue.dueDate)}
{shortDate(issue.due_date)}
</span>
) : (
<span className="text-muted-foreground">None</span>
@ -277,17 +282,21 @@ export default function IssueDetailPage({
</PropRow>
<PropRow label="Created by">
<Avatar person={issue.creator} size={18} />
<span>{issue.creator.name}</span>
<ActorAvatar
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
/>
<span>{getActorName(issue.creator_type, issue.creator_id)}</span>
</PropRow>
</div>
<div className="mt-4 border-t pt-3 space-y-0.5">
<PropRow label="Created">
<span className="text-muted-foreground">{shortDate(issue.createdAt)}</span>
<span className="text-muted-foreground">{shortDate(issue.created_at)}</span>
</PropRow>
<PropRow label="Updated">
<span className="text-muted-foreground">{shortDate(issue.updatedAt)}</span>
<span className="text-muted-foreground">{shortDate(issue.updated_at)}</span>
</PropRow>
</div>
</div>

View file

@ -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 ----

View file

@ -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 (
<div
className={`flex shrink-0 items-center justify-center rounded-full font-medium ${sizeClass} ${
assignee.type === "agent"
issue.assignee_type === "agent"
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
: "bg-muted text-muted-foreground"
}`}
title={assignee.name}
title={name}
>
{assignee.type === "agent" ? (
{issue.assignee_type === "agent" ? (
<Bot className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
) : (
assignee.avatar.charAt(0)
initials
)}
</div>
);
@ -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 (
<div className="rounded-lg border bg-background p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<PriorityIcon priority={issue.priority} />
<span>{issue.key}</span>
<span>{issue.id.slice(0, 8)}</span>
</div>
<p className="mt-1.5 text-[13px] leading-snug">{issue.title}</p>
<div className="mt-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<AssigneeAvatar assignee={issue.assignee} />
{issue.comments.length > 0 && (
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{issue.comments.length}
</span>
)}
<AssigneeAvatar issue={issue} />
</div>
{issue.dueDate && (
{issue.due_date && (
<span className="text-xs text-muted-foreground">
{formatDate(issue.dueDate)}
{formatDate(issue.due_date)}
</span>
)}
</div>
@ -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 }) {
<Link
href={`/issues/${issue.id}`}
onClick={(e) => {
// 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<MockIssue | null>(null);
const [activeIssue, setActiveIssue] = useState<Issue | null>(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 (
<Link
href={`/issues/${issue.id}`}
className="flex h-9 items-center gap-2 px-4 text-[13px] transition-colors hover:bg-accent/50"
>
<PriorityIcon priority={issue.priority} />
<span className="w-16 shrink-0 text-xs text-muted-foreground">{issue.key}</span>
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{issue.id.slice(0, 8)}
</span>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
{issue.dueDate && (
{issue.due_date && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatDate(issue.dueDate)}
{formatDate(issue.due_date)}
</span>
)}
<AssigneeAvatar assignee={issue.assignee} />
<AssigneeAvatar issue={issue} />
</Link>
);
}
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 (
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus className="h-3.5 w-3.5" />
New Issue
</button>
);
}
return (
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<input
autoFocus
type="text"
value={title}
onChange={(e) => 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"
/>
<button
type="submit"
className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground"
>
Create
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="text-xs text-muted-foreground"
>
Cancel
</button>
</form>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
@ -398,21 +453,78 @@ type ViewMode = "board" | "list";
export default function IssuesPage() {
const [view, setView] = useState<ViewMode>("board");
const [issues, setIssues] = useState<MockIssue[]>(MOCK_ISSUES);
const [issues, setIssues] = useState<Issue[]>([]);
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 (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
@ -444,10 +556,7 @@ export default function IssuesPage() {
</button>
</div>
</div>
<button className="flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/90">
<Plus className="h-3.5 w-3.5" />
New Issue
</button>
<CreateIssueForm onCreated={handleIssueCreated} />
</div>
<div className="flex-1 overflow-hidden">

View file

@ -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 (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6" />
</div>
);
}
if (!user) return null;
return (
<div className="flex h-screen bg-canvas">
{/* Sidebar — sits on the canvas layer */}
{/* Sidebar */}
<aside className="flex w-56 shrink-0 flex-col">
{/* Workspace Switcher */}
<div className="flex h-12 items-center gap-2 px-3">
<MulticaIcon className="size-4" noSpin />
<span className="flex-1 truncate text-sm font-semibold">
Multica
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="flex h-12 w-full items-center gap-2 px-3 hover:bg-sidebar-accent/50 transition-colors"
>
<MulticaIcon className="size-4" noSpin />
<span className="flex-1 truncate text-left text-sm font-semibold">
{workspace?.name ?? "Multica"}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
{showMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowMenu(false)}
/>
<div className="absolute left-2 top-12 z-50 w-52 rounded-lg border bg-popover p-1 shadow-md">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{user.email}
</div>
<div className="my-1 border-t" />
<Link
href="/settings"
onClick={() => setShowMenu(false)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
>
<Settings className="h-3.5 w-3.5" />
Settings
</Link>
<button
onClick={() => {
setShowMenu(false);
logout();
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
>
<LogOut className="h-3.5 w-3.5" />
Sign out
</button>
</div>
</>
)}
</div>
{/* Navigation */}
@ -59,9 +123,26 @@ export default function DashboardLayout({
);
})}
</nav>
{/* User info at bottom */}
<div className="border-t px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium">
{user.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<span className="truncate text-xs text-muted-foreground">
{user.name}
</span>
</div>
</div>
</aside>
{/* Main content — floating panel on top of the canvas */}
{/* Main content */}
<div className="flex-1 pt-1.5 pr-1.5 pb-1.5">
<main className="h-full overflow-auto rounded-xl bg-background shadow-sm">
{children}

View file

@ -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<MemberRole, { label: string; icon: typeof Crown }> = {
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 (
<div className="p-6">
<h1 className="text-2xl font-bold">Settings</h1>
<p className="mt-2 text-muted-foreground">
Workspace settings coming soon.
</p>
<div className="flex items-center gap-3 rounded-lg border px-4 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{member.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{member.name}</div>
<div className="text-xs text-muted-foreground">{member.email}</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RoleIcon className="h-3 w-3" />
{rc.label}
</div>
</div>
);
}
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 (
<div className="mx-auto max-w-2xl p-6 space-y-8">
{/* Page header */}
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Settings</h1>
</div>
{/* Workspace info */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Workspace</h2>
</div>
<div className="space-y-3 rounded-lg border p-4">
<div>
<label className="text-xs font-medium text-muted-foreground">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
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 resize-none"
placeholder="What does this workspace focus on?"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
Slug
</label>
<div className="mt-1 rounded-md border bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
{workspace.slug}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
{saved && (
<span className="text-xs text-green-600">Saved!</span>
)}
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</section>
{/* Members */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">
Members ({members.length})
</h2>
</div>
</div>
<div className="space-y-2">
{members.map((m) => (
<MemberRow key={m.id} member={m} />
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground">No members found.</p>
)}
</div>
</section>
</div>
);
}

View file

@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { ThemeProvider } from "@multica/ui/components/theme-provider";
import { AuthProvider } from "../lib/auth-context";
import { WSProvider } from "../lib/ws-context";
import "./globals.css";
export const metadata: Metadata = {
@ -21,7 +23,9 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
{children}
<AuthProvider>
<WSProvider>{children}</WSProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>

View file

@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/inbox");
redirect("/issues");
}

17
apps/web/lib/api.ts Normal file
View file

@ -0,0 +1,17 @@
import { ApiClient } from "@multica/sdk";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
export const api = new ApiClient(API_BASE_URL);
// Initialize token from localStorage on load
if (typeof window !== "undefined") {
const token = localStorage.getItem("multica_token");
if (token) {
api.setToken(token);
}
const wsId = localStorage.getItem("multica_workspace_id");
if (wsId) {
api.setWorkspaceId(wsId);
}
}

View file

@ -0,0 +1,281 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
// Mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
// Must use vi.hoisted so the mock object is defined before vi.mock factory runs
const mockApi = vi.hoisted(() => ({
setToken: vi.fn(),
setWorkspaceId: vi.fn(),
login: vi.fn(),
getMe: vi.fn(),
listWorkspaces: vi.fn(),
listMembers: vi.fn(),
listAgents: vi.fn(),
}));
vi.mock("./api", () => ({
api: mockApi,
}));
import { AuthProvider, useAuth } from "./auth-context";
const mockUser: User = {
id: "user-1",
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const mockWorkspace: Workspace = {
id: "ws-1",
name: "Test WS",
slug: "test",
description: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const mockMembers: MemberWithUser[] = [
{
id: "member-1",
workspace_id: "ws-1",
user_id: "user-1",
role: "owner",
created_at: "2026-01-01T00:00:00Z",
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
},
{
id: "member-2",
workspace_id: "ws-1",
user_id: "user-2",
role: "member",
created_at: "2026-01-01T00:00:00Z",
name: "Other User",
email: "other@multica.ai",
avatar_url: null,
},
];
const mockAgents: Agent[] = [
{
id: "agent-1",
workspace_id: "ws-1",
name: "Claude",
status: "idle",
runtime_mode: "cloud",
visibility: "workspace",
max_concurrent_tasks: 3,
description: null,
system_prompt: null,
config: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
function wrapper({ children }: { children: React.ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
}
describe("AuthContext", () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear localStorage manually since jsdom may not have .clear()
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
});
it("starts with null user when no token stored", async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.user).toBeNull();
expect(result.current.workspace).toBeNull();
});
it("login stores token and navigates to /issues", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai", "Test User");
});
expect(mockApi.login).toHaveBeenCalledWith("test@multica.ai", "Test User");
expect(mockApi.setToken).toHaveBeenCalledWith("test-jwt");
expect(localStorage.getItem("multica_token")).toBe("test-jwt");
expect(result.current.user).toEqual(mockUser);
expect(result.current.workspace).toEqual(mockWorkspace);
expect(result.current.members).toEqual(mockMembers);
expect(result.current.agents).toEqual(mockAgents);
expect(mockPush).toHaveBeenCalledWith("/issues");
});
it("logout clears state and navigates to /login", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
act(() => {
result.current.logout();
});
expect(localStorage.getItem("multica_token")).toBeNull();
expect(localStorage.getItem("multica_workspace_id")).toBeNull();
expect(result.current.user).toBeNull();
expect(result.current.workspace).toBeNull();
expect(result.current.members).toEqual([]);
expect(result.current.agents).toEqual([]);
expect(mockPush).toHaveBeenCalledWith("/login");
});
it("getMemberName returns correct name for known user", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getMemberName("user-1")).toBe("Test User");
expect(result.current.getMemberName("user-2")).toBe("Other User");
expect(result.current.getMemberName("unknown")).toBe("Unknown");
});
it("getAgentName returns correct name for known agent", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getAgentName("agent-1")).toBe("Claude");
expect(result.current.getAgentName("unknown")).toBe("Unknown Agent");
});
it("getActorName dispatches to member or agent", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getActorName("member", "user-1")).toBe("Test User");
expect(result.current.getActorName("agent", "agent-1")).toBe("Claude");
expect(result.current.getActorName("system", "xxx")).toBe("System");
});
it("getActorInitials returns uppercase initials", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getActorInitials("member", "user-1")).toBe("TU");
expect(result.current.getActorInitials("agent", "agent-1")).toBe("C");
});
it("initializes from localStorage token on mount", async () => {
localStorage.setItem("multica_token", "stored-token");
localStorage.setItem("multica_workspace_id", "ws-1");
mockApi.getMe.mockResolvedValueOnce(mockUser);
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockApi.setToken).toHaveBeenCalledWith("stored-token");
expect(result.current.user).toEqual(mockUser);
expect(result.current.workspace).toEqual(mockWorkspace);
});
it("clears token when stored token is invalid", async () => {
localStorage.setItem("multica_token", "invalid-token");
mockApi.getMe.mockRejectedValueOnce(new Error("Unauthorized"));
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.user).toBeNull();
expect(localStorage.getItem("multica_token")).toBeNull();
});
});

View file

@ -0,0 +1,194 @@
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import { useRouter } from "next/navigation";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
import { api } from "./api";
interface AuthContextValue {
user: User | null;
workspace: Workspace | null;
members: MemberWithUser[];
agents: Agent[];
isLoading: boolean;
login: (email: string, name?: string) => Promise<void>;
logout: () => void;
refreshMembers: () => Promise<void>;
refreshAgents: () => Promise<void>;
getMemberName: (userId: string) => string;
getAgentName: (agentId: string) => string;
getActorName: (type: string, id: string) => string;
getActorInitials: (type: string, id: string) => string;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [members, setMembers] = useState<MemberWithUser[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
// Initialize from stored token
useEffect(() => {
const token = localStorage.getItem("multica_token");
const wsId = localStorage.getItem("multica_workspace_id");
if (!token) {
setIsLoading(false);
return;
}
api.setToken(token);
if (wsId) api.setWorkspaceId(wsId);
(async () => {
try {
const me = await api.getMe();
setUser(me);
const workspaces = await api.listWorkspaces();
if (workspaces.length > 0) {
const ws = workspaces[0]!;
setWorkspace(ws);
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
const [m, a] = await Promise.all([
api.listMembers(ws.id),
api.listAgents({ workspace_id: ws.id }),
]);
setMembers(m);
setAgents(a);
}
} catch {
// Token invalid, clear it
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
} finally {
setIsLoading(false);
}
})();
}, []);
const login = useCallback(async (email: string, name?: string) => {
const { token, user: u } = await api.login(email, name);
api.setToken(token);
localStorage.setItem("multica_token", token);
setUser(u);
// Load workspace
const workspaces = await api.listWorkspaces();
if (workspaces.length > 0) {
const ws = workspaces[0]!;
setWorkspace(ws);
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
const [m, a] = await Promise.all([
api.listMembers(ws.id),
api.listAgents({ workspace_id: ws.id }),
]);
setMembers(m);
setAgents(a);
}
router.push("/issues");
}, [router]);
const logout = useCallback(() => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
setUser(null);
setWorkspace(null);
setMembers([]);
setAgents([]);
router.push("/login");
}, [router]);
const refreshMembers = useCallback(async () => {
if (!workspace) return;
const m = await api.listMembers(workspace.id);
setMembers(m);
}, [workspace]);
const refreshAgents = useCallback(async () => {
if (!workspace) return;
const a = await api.listAgents({ workspace_id: workspace.id });
setAgents(a);
}, [workspace]);
const getMemberName = useCallback(
(userId: string) => {
const m = members.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";
},
[members],
);
const getAgentName = useCallback(
(agentId: string) => {
const a = agents.find((a) => a.id === agentId);
return a?.name ?? "Unknown Agent";
},
[agents],
);
const getActorName = useCallback(
(type: string, id: string) => {
if (type === "member") return getMemberName(id);
if (type === "agent") return getAgentName(id);
return "System";
},
[getMemberName, getAgentName],
);
const getActorInitials = useCallback(
(type: string, id: string) => {
const name = getActorName(type, id);
return name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
},
[getActorName],
);
return (
<AuthContext.Provider
value={{
user,
workspace,
members,
agents,
isLoading,
login,
logout,
refreshMembers,
refreshAgents,
getMemberName,
getAgentName,
getActorName,
getActorInitials,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View file

@ -0,0 +1,75 @@
"use client";
import {
createContext,
useContext,
useEffect,
useRef,
useCallback,
type ReactNode,
} from "react";
import { WSClient } from "@multica/sdk";
import type { WSEventType } from "@multica/types";
import { useAuth } from "./auth-context";
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws";
type EventHandler = (payload: unknown) => void;
interface WSContextValue {
subscribe: (event: WSEventType, handler: EventHandler) => () => void;
}
const WSContext = createContext<WSContextValue | null>(null);
export function WSProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const wsRef = useRef<WSClient | null>(null);
useEffect(() => {
if (!user) return;
const ws = new WSClient(WS_URL);
wsRef.current = ws;
ws.connect();
return () => {
ws.disconnect();
wsRef.current = null;
};
}, [user]);
const subscribe = useCallback(
(event: WSEventType, handler: EventHandler) => {
const ws = wsRef.current;
if (!ws) return () => {};
return ws.on(event, handler);
},
[],
);
return (
<WSContext.Provider value={{ subscribe }}>
{children}
</WSContext.Provider>
);
}
export function useWS() {
const ctx = useContext(WSContext);
if (!ctx) throw new Error("useWS must be used within WSProvider");
return ctx;
}
/**
* Hook that subscribes to a WebSocket event and calls the handler.
* Automatically unsubscribes on cleanup.
*/
export function useWSEvent(event: WSEventType, handler: EventHandler) {
const { subscribe } = useWS();
useEffect(() => {
const unsub = subscribe(event, handler);
return unsub;
}, [event, handler, subscribe]);
}