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:
parent
1e61c1974c
commit
78f4d88aa1
14 changed files with 1315 additions and 642 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
17
apps/web/lib/api.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
281
apps/web/lib/auth-context.test.tsx
Normal file
281
apps/web/lib/auth-context.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
194
apps/web/lib/auth-context.tsx
Normal file
194
apps/web/lib/auth-context.tsx
Normal 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;
|
||||
}
|
||||
75
apps/web/lib/ws-context.tsx
Normal file
75
apps/web/lib/ws-context.tsx
Normal 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]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue