Merge pull request #233 from multica-ai/forrestchang/linear-sidebar-ui
feat(web): Linear-inspired UI with Inbox, Agents, Issues, and Knowledge Base
This commit is contained in:
commit
d75746021f
13 changed files with 5369 additions and 49 deletions
98
README.md
Normal file
98
README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Multica
|
||||
|
||||
AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (v20+)
|
||||
- [pnpm](https://pnpm.io/) (v10.28+)
|
||||
- [Go](https://go.dev/) (v1.26+)
|
||||
- [Docker](https://www.docker.com/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
pnpm install
|
||||
|
||||
# 2. Copy environment variables
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Start PostgreSQL
|
||||
docker compose up -d
|
||||
|
||||
# 4. Run database migrations
|
||||
make migrate-up
|
||||
|
||||
# 5. Start the Go backend (port 8080)
|
||||
make dev
|
||||
|
||||
# 6. In another terminal, start the frontend (port 3000)
|
||||
pnpm dev:web
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── server/ # Go backend (Chi + sqlc + gorilla/websocket)
|
||||
│ ├── cmd/ # server, daemon, migrate, seed
|
||||
│ ├── internal/ # Core business logic
|
||||
│ ├── migrations/ # SQL migrations
|
||||
│ └── sqlc.yaml # sqlc config
|
||||
├── apps/
|
||||
│ └── web/ # Next.js 16 frontend
|
||||
├── packages/ # Shared TypeScript packages
|
||||
│ ├── ui/ # Component library (shadcn/ui + Radix)
|
||||
│ ├── types/ # Shared type definitions
|
||||
│ ├── sdk/ # API client SDK
|
||||
│ ├── store/ # State management
|
||||
│ ├── hooks/ # Shared React hooks
|
||||
│ └── utils/ # Utility functions
|
||||
├── Makefile # Backend commands
|
||||
├── docker-compose.yml # PostgreSQL + pgvector
|
||||
└── .env.example # Environment variable template
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Frontend
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm dev:web` | Start Next.js dev server (port 3000) |
|
||||
| `pnpm build` | Build all TypeScript packages |
|
||||
| `pnpm typecheck` | Run TypeScript type checking |
|
||||
| `pnpm test` | Run TypeScript tests |
|
||||
|
||||
### Backend
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make dev` | Run Go server (port 8080) |
|
||||
| `make daemon` | Run local agent daemon |
|
||||
| `make test` | Run Go tests |
|
||||
| `make build` | Build server & daemon binaries |
|
||||
| `make sqlc` | Regenerate sqlc code from SQL |
|
||||
|
||||
### Database
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `docker compose up -d` | Start PostgreSQL |
|
||||
| `docker compose down` | Stop PostgreSQL |
|
||||
| `make migrate-up` | Run database migrations |
|
||||
| `make migrate-down` | Rollback database migrations |
|
||||
| `make seed` | Seed test data |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See [`.env.example`](.env.example) for all available variables:
|
||||
|
||||
- `DATABASE_URL` — PostgreSQL connection string
|
||||
- `PORT` — Backend server port (default: 8080)
|
||||
- `JWT_SECRET` — JWT signing secret
|
||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` — Google OAuth (optional)
|
||||
- `NEXT_PUBLIC_API_URL` — Frontend → backend API URL
|
||||
- `NEXT_PUBLIC_WS_URL` — Frontend → backend WebSocket URL
|
||||
|
|
@ -1,15 +1,467 @@
|
|||
export default function AgentsPage() {
|
||||
"use client";
|
||||
|
||||
import { useState } 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",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const statusConfig: Record<AgentStatus, { label: string; color: string; dot: string }> = {
|
||||
idle: { label: "Idle", color: "text-muted-foreground", dot: "bg-muted-foreground" },
|
||||
working: { label: "Working", color: "text-green-600", dot: "bg-green-500" },
|
||||
blocked: { label: "Blocked", color: "text-yellow-600", dot: "bg-yellow-500" },
|
||||
error: { label: "Error", color: "text-red-600", dot: "bg-red-500" },
|
||||
offline: { label: "Offline", color: "text-muted-foreground/50", dot: "bg-muted-foreground/40" },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentListItem({
|
||||
agent,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
agent: MockAgent;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Agents</h1>
|
||||
<button className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground">
|
||||
Add Agent
|
||||
</button>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors ${
|
||||
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}
|
||||
</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" ? (
|
||||
<Cloud className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Monitor className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<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 }) {
|
||||
const st = statusConfig[agent.status];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
{/* 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}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold">{agent.name}</h2>
|
||||
<span className={`flex items-center gap-1.5 text-sm ${st.color}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{agent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg border p-4">
|
||||
<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" ? (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Model</div>
|
||||
<div className="mt-1 text-sm font-medium">{agent.model}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Concurrency</div>
|
||||
<div className="mt-1 text-sm font-medium">
|
||||
{agent.currentTasks.filter((t) => t.status === "working").length} / {agent.maxConcurrentTasks} slots
|
||||
</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 */}
|
||||
<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>
|
||||
</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>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No active tasks</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function AgentsPage() {
|
||||
const [selectedId, setSelectedId] = useState<string>(MOCK_AGENTS[0]?.id ?? "");
|
||||
const selected = MOCK_AGENTS.find((a) => a.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left column — agent list */}
|
||||
<div className="w-72 shrink-0 overflow-y-auto border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<h1 className="text-sm font-semibold">Agents</h1>
|
||||
<button className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-accent">
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{MOCK_AGENTS.map((agent) => (
|
||||
<AgentListItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isSelected={agent.id === selectedId}
|
||||
onClick={() => setSelectedId(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column — agent detail */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selected ? (
|
||||
<AgentDetail agent={selected} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
Select an agent to view details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
No agents configured yet. Add your first agent to start automating tasks.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,291 @@
|
|||
export default function InboxPage() {
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
GitPullRequest,
|
||||
MessageSquare,
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const severityOrder: Record<InboxSeverity, number> = {
|
||||
action_required: 0,
|
||||
attention: 1,
|
||||
info: 2,
|
||||
};
|
||||
|
||||
const typeIcons: Record<InboxItemType, typeof AlertCircle> = {
|
||||
agent_blocked: AlertCircle,
|
||||
review_requested: GitPullRequest,
|
||||
issue_assigned: CircleDot,
|
||||
agent_completed: CheckCircle2,
|
||||
mentioned: MessageSquare,
|
||||
status_change: ArrowRightLeft,
|
||||
};
|
||||
|
||||
const severityColors: Record<InboxSeverity, string> = {
|
||||
action_required: "text-red-500",
|
||||
attention: "text-yellow-500",
|
||||
info: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function InboxListItem({
|
||||
item,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
item: InboxItem;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const Icon = typeIcons[item.type];
|
||||
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"
|
||||
} ${!item.read ? "font-medium" : ""}`}
|
||||
>
|
||||
<Icon className={`mt-0.5 h-4 w-4 shrink-0 ${colorClass}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm">{item.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{timeAgo(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{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" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxDetail({ item }: { item: InboxItem }) {
|
||||
const Icon = typeIcons[item.type];
|
||||
const colorClass = severityColors[item.severity];
|
||||
|
||||
const severityLabel: Record<InboxSeverity, string> = {
|
||||
action_required: "Action required",
|
||||
attention: "Needs attention",
|
||||
info: "Info",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">Inbox</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Your notifications and action items will appear here.
|
||||
</p>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={`mt-1 h-5 w-5 shrink-0 ${colorClass}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-semibold">{item.title}</h2>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{item.body && (
|
||||
<div className="mt-6 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
|
||||
{item.body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 [selectedId, setSelectedId] = useState<string>(sorted[0]?.id ?? "");
|
||||
const selected = sorted.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left column — inbox list */}
|
||||
<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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column — detail */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selected ? (
|
||||
<InboxDetail item={selected} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
Select an item to view details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,297 @@
|
|||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Bot,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
User,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
MOCK_ISSUES,
|
||||
STATUS_CONFIG,
|
||||
PRIORITY_CONFIG,
|
||||
} from "../_data/mock";
|
||||
import type { MockAssignee } from "../_data/mock";
|
||||
import { StatusIcon, PriorityIcon } from "../page";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function formatDate(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",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Avatar({
|
||||
person,
|
||||
size = 20,
|
||||
}: {
|
||||
person: MockAssignee;
|
||||
size?: number;
|
||||
}) {
|
||||
const isAgent = person.type === "agent";
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium ${
|
||||
isAgent
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
style={{ width: size, height: size, fontSize: size * 0.45 }}
|
||||
title={person.name}
|
||||
>
|
||||
{isAgent ? <Bot style={{ width: size * 0.55, height: size * 0.55 }} /> : person.avatar.charAt(0)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property row (Linear-style: label left, clickable value right)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PropRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[32px] items-center gap-3 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors">
|
||||
<span className="w-20 shrink-0 text-[13px] text-muted-foreground">{label}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5 text-[13px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function IssueDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const issue = MOCK_ISSUES.find((i) => i.id === id);
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Issue not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">Issue Detail</h1>
|
||||
<p className="mt-2 text-muted-foreground">Issue detail view</p>
|
||||
<div className="flex h-full">
|
||||
{/* ================================================================
|
||||
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]">
|
||||
<Link
|
||||
href="/issues"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Issues
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<span className="truncate text-muted-foreground">{issue.key}</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>
|
||||
|
||||
{/* 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 */}
|
||||
<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" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-foreground/70">
|
||||
{entry.actor.name}
|
||||
</span>{" "}
|
||||
{entry.content}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0">{timeAgo(entry.createdAt)}</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================================================================
|
||||
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">
|
||||
Properties
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<PropRow label="Status">
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className={statusCfg.iconColor}>{statusCfg.label}</span>
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Priority">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span>{priorityCfg.label}</span>
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Assignee">
|
||||
{issue.assignee ? (
|
||||
<>
|
||||
<Avatar person={issue.assignee} size={18} />
|
||||
<span>{issue.assignee.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Due date">
|
||||
{issue.dueDate ? (
|
||||
<span className={isOverdue ? "text-red-500" : ""}>
|
||||
{shortDate(issue.dueDate)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">None</span>
|
||||
)}
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Created by">
|
||||
<Avatar person={issue.creator} size={18} />
|
||||
<span>{issue.creator.name}</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>
|
||||
</PropRow>
|
||||
<PropRow label="Updated">
|
||||
<span className="text-muted-foreground">{shortDate(issue.updatedAt)}</span>
|
||||
</PropRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
436
apps/web/app/(dashboard)/issues/_data/mock.ts
Normal file
436
apps/web/app/(dashboard)/issues/_data/mock.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import type { IssueStatus, IssuePriority } from "@multica/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extended types for mock UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MockAssignee {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
type: "member" | "agent";
|
||||
}
|
||||
|
||||
export interface MockComment {
|
||||
id: string;
|
||||
author: MockAssignee;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MockActivity {
|
||||
id: string;
|
||||
actor: MockAssignee;
|
||||
action: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MockIssue {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: IssueStatus;
|
||||
priority: IssuePriority;
|
||||
assignee: MockAssignee | null;
|
||||
creator: MockAssignee;
|
||||
dueDate: string | null;
|
||||
comments: MockComment[];
|
||||
activity: MockActivity[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// People & Agents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PEOPLE: Record<string, MockAssignee> = {
|
||||
jiayuan: { id: "usr_1", name: "Jiayuan", avatar: "JY", type: "member" },
|
||||
bohan: { id: "usr_2", name: "Bohan", avatar: "BH", type: "member" },
|
||||
yuzhen: { id: "usr_3", name: "Yuzhen", avatar: "YZ", type: "member" },
|
||||
claude1: { id: "agent_1", name: "Claude-1", avatar: "C1", type: "agent" },
|
||||
codex1: { id: "agent_2", name: "Codex-1", avatar: "CX", type: "agent" },
|
||||
reviewBot: { id: "agent_3", name: "Review Bot", avatar: "RB", type: "agent" },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status & Priority config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const STATUS_ORDER: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
export const STATUS_CONFIG: Record<
|
||||
IssueStatus,
|
||||
{ label: string; iconColor: string }
|
||||
> = {
|
||||
backlog: { label: "Backlog", iconColor: "text-muted-foreground" },
|
||||
todo: { label: "Todo", iconColor: "text-muted-foreground" },
|
||||
in_progress: { label: "In Progress", iconColor: "text-yellow-500" },
|
||||
in_review: { label: "In Review", iconColor: "text-blue-500" },
|
||||
done: { label: "Done", iconColor: "text-green-500" },
|
||||
blocked: { label: "Blocked", iconColor: "text-red-500" },
|
||||
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground/50" },
|
||||
};
|
||||
|
||||
export const PRIORITY_CONFIG: Record<
|
||||
IssuePriority,
|
||||
{ label: string; bars: number; color: string }
|
||||
> = {
|
||||
urgent: { label: "Urgent", bars: 4, color: "text-orange-500" },
|
||||
high: { label: "High", bars: 3, color: "text-orange-400" },
|
||||
medium: { label: "Medium", bars: 2, color: "text-yellow-500" },
|
||||
low: { label: "Low", bars: 1, color: "text-blue-400" },
|
||||
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock Issues
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { jiayuan, bohan, yuzhen, claude1, codex1, reviewBot } = PEOPLE;
|
||||
|
||||
export const MOCK_ISSUES: MockIssue[] = [
|
||||
// ---- Backlog ----
|
||||
{
|
||||
id: "iss_20",
|
||||
key: "MUL-20",
|
||||
title: "Add multi-workspace support",
|
||||
description:
|
||||
"Allow users to create and switch between multiple workspaces. Each workspace should have isolated issues, agents, and knowledge base.",
|
||||
status: "backlog",
|
||||
priority: "low",
|
||||
assignee: null,
|
||||
creator: jiayuan,
|
||||
dueDate: null,
|
||||
comments: [],
|
||||
activity: [
|
||||
{ id: "act_20_1", actor: jiayuan, action: "created this issue", createdAt: "2026-03-18T10:00:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-18T10:00:00Z",
|
||||
updatedAt: "2026-03-18T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "iss_21",
|
||||
key: "MUL-21",
|
||||
title: "Agent long-term memory persistence",
|
||||
description:
|
||||
"Implement a memory system for agents that persists across task executions. Should support both vector embeddings and structured key-value storage.",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
assignee: null,
|
||||
creator: bohan,
|
||||
dueDate: null,
|
||||
comments: [],
|
||||
activity: [
|
||||
{ id: "act_21_1", actor: bohan, action: "created this issue", createdAt: "2026-03-19T08:00:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-19T08:00:00Z",
|
||||
updatedAt: "2026-03-19T08:00:00Z",
|
||||
},
|
||||
|
||||
// ---- Todo ----
|
||||
{
|
||||
id: "iss_15",
|
||||
key: "MUL-15",
|
||||
title: "Design the agent config UI",
|
||||
description:
|
||||
"We 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.",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
assignee: jiayuan,
|
||||
creator: bohan,
|
||||
dueDate: "2026-03-25T00:00:00Z",
|
||||
comments: [
|
||||
{
|
||||
id: "cmt_15_1",
|
||||
author: bohan,
|
||||
body: "Let's keep this simple for MVP — just runtime selection and concurrency slider.",
|
||||
createdAt: "2026-03-20T09:00:00Z",
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{ id: "act_15_1", actor: bohan, action: "created this issue", createdAt: "2026-03-20T08:00:00Z" },
|
||||
{ id: "act_15_2", actor: bohan, action: "assigned this to Jiayuan", createdAt: "2026-03-20T08:00:00Z" },
|
||||
{ id: "act_15_3", actor: bohan, action: "set priority to High", createdAt: "2026-03-20T08:01:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-20T08:00:00Z",
|
||||
updatedAt: "2026-03-20T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "iss_16",
|
||||
key: "MUL-16",
|
||||
title: "Implement knowledge base document editor",
|
||||
description:
|
||||
"Build a Markdown editor for creating and editing knowledge base documents. Should support basic formatting, code blocks, and image uploads.",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assignee: codex1,
|
||||
creator: yuzhen,
|
||||
dueDate: "2026-03-28T00:00:00Z",
|
||||
comments: [],
|
||||
activity: [
|
||||
{ id: "act_16_1", actor: yuzhen, action: "created this issue", createdAt: "2026-03-19T14:00:00Z" },
|
||||
{ id: "act_16_2", actor: yuzhen, action: "assigned this to Codex-1", createdAt: "2026-03-19T14:01:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-19T14:00:00Z",
|
||||
updatedAt: "2026-03-19T14:01:00Z",
|
||||
},
|
||||
{
|
||||
id: "iss_17",
|
||||
key: "MUL-17",
|
||||
title: "Add issue dependency tracking",
|
||||
description: "Support blocking/blocked-by relationships between issues. Show dependency graph in issue detail view.",
|
||||
status: "todo",
|
||||
priority: "low",
|
||||
assignee: null,
|
||||
creator: jiayuan,
|
||||
dueDate: null,
|
||||
comments: [],
|
||||
activity: [
|
||||
{ id: "act_17_1", actor: jiayuan, action: "created this issue", createdAt: "2026-03-20T11:00:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-20T11:00:00Z",
|
||||
updatedAt: "2026-03-20T11:00:00Z",
|
||||
},
|
||||
|
||||
// ---- In Progress ----
|
||||
{
|
||||
id: "iss_9",
|
||||
key: "MUL-9",
|
||||
title: "Implement issue list API endpoint",
|
||||
description:
|
||||
"Build the REST API endpoint for listing and filtering issues.\n\n## Requirements\n- Pagination with cursor-based approach\n- Filter by status, priority, assignee\n- Sort by priority, status, created_at\n- Include assignee info in response",
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
assignee: claude1,
|
||||
creator: jiayuan,
|
||||
dueDate: "2026-03-22T00:00:00Z",
|
||||
comments: [
|
||||
{
|
||||
id: "cmt_9_1",
|
||||
author: claude1,
|
||||
body: "Started working on this. Using sqlc for the query generation. I'll implement cursor-based pagination with the `created_at` + `id` compound cursor.",
|
||||
createdAt: "2026-03-20T14:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "cmt_9_2",
|
||||
author: jiayuan,
|
||||
body: "Sounds good. Make sure to add an index on (status, created_at) for the filtered queries.",
|
||||
createdAt: "2026-03-20T14:30:00Z",
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{ id: "act_9_1", actor: jiayuan, action: "created this issue", createdAt: "2026-03-19T10:00:00Z" },
|
||||
{ id: "act_9_2", actor: jiayuan, action: "assigned this to Claude-1", createdAt: "2026-03-19T10:00:00Z" },
|
||||
{ id: "act_9_3", actor: claude1, action: "moved this to In Progress", createdAt: "2026-03-20T13:00:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-19T10:00:00Z",
|
||||
updatedAt: "2026-03-20T14:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "iss_12",
|
||||
key: "MUL-12",
|
||||
title: "Implement OAuth login flow",
|
||||
description:
|
||||
"Set up Google OAuth for user authentication. Include PKCE flow for the SPA and session management on the server side.",
|
||||
status: "in_progress",
|
||||
priority: "urgent",
|
||||
assignee: claude1,
|
||||
creator: jiayuan,
|
||||
dueDate: "2026-03-21T00:00:00Z",
|
||||
comments: [
|
||||
{
|
||||
id: "cmt_12_1",
|
||||
author: claude1,
|
||||
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?",
|
||||
createdAt: "2026-03-21T05:32:00Z",
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{ id: "act_12_1", actor: jiayuan, action: "created this issue", createdAt: "2026-03-18T09:00:00Z" },
|
||||
{ id: "act_12_2", actor: jiayuan, action: "assigned this to Claude-1", createdAt: "2026-03-18T09:01:00Z" },
|
||||
{ id: "act_12_3", actor: claude1, action: "moved this to In Progress", createdAt: "2026-03-20T10:00:00Z" },
|
||||
{ id: "act_12_4", actor: claude1, action: "marked as Blocked", createdAt: "2026-03-21T05:32:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-18T09:00:00Z",
|
||||
updatedAt: "2026-03-21T05:32:00Z",
|
||||
},
|
||||
{
|
||||
id: "iss_10",
|
||||
key: "MUL-10",
|
||||
title: "Set up pgvector for knowledge base embeddings",
|
||||
description:
|
||||
"Configure pgvector extension and create the embeddings table for semantic search in the knowledge base.",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assignee: yuzhen,
|
||||
creator: yuzhen,
|
||||
dueDate: "2026-03-24T00:00:00Z",
|
||||
comments: [
|
||||
{
|
||||
id: "cmt_10_1",
|
||||
author: yuzhen,
|
||||
body: "@jiayuan Can you take a look at the database schema? I want to make sure the vector embeddings table is set up correctly before we start indexing.",
|
||||
createdAt: "2026-03-20T18:30:00Z",
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{ id: "act_10_1", actor: yuzhen, action: "created this issue", createdAt: "2026-03-19T11:00:00Z" },
|
||||
{ id: "act_10_2", actor: yuzhen, action: "moved this to In Progress", createdAt: "2026-03-20T09:00:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-19T11:00:00Z",
|
||||
updatedAt: "2026-03-20T18:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "iss_14",
|
||||
key: "MUL-14",
|
||||
title: "Add WebSocket event types for agent status",
|
||||
description: "Define and implement WebSocket message types for real-time agent status updates (idle, working, blocked, error, offline).",
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
assignee: bohan,
|
||||
creator: bohan,
|
||||
dueDate: "2026-03-23T00:00:00Z",
|
||||
comments: [],
|
||||
activity: [
|
||||
{ id: "act_14_1", actor: bohan, action: "created this issue", createdAt: "2026-03-20T08:00:00Z" },
|
||||
{ id: "act_14_2", actor: bohan, action: "moved this to In Progress", createdAt: "2026-03-20T16:00:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-20T08:00:00Z",
|
||||
updatedAt: "2026-03-20T16:00:00Z",
|
||||
},
|
||||
|
||||
// ---- In Review ----
|
||||
{
|
||||
id: "iss_8",
|
||||
key: "MUL-8",
|
||||
title: "Add WebSocket reconnection logic",
|
||||
description:
|
||||
"Implement exponential backoff for WebSocket reconnection in the daemon. Include configurable max retry attempts.",
|
||||
status: "in_review",
|
||||
priority: "high",
|
||||
assignee: codex1,
|
||||
creator: bohan,
|
||||
dueDate: "2026-03-21T00:00:00Z",
|
||||
comments: [
|
||||
{
|
||||
id: "cmt_8_1",
|
||||
author: codex1,
|
||||
body: "PR #47 submitted. Chose exponential backoff over linear retry because of the bursty reconnection pattern observed in daemon logs.",
|
||||
createdAt: "2026-03-21T04:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "cmt_8_2",
|
||||
author: reviewBot,
|
||||
body: "Code review passed. No security issues found. Minor suggestion: consider adding jitter to the backoff to avoid thundering herd.",
|
||||
createdAt: "2026-03-21T04:30:00Z",
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{ id: "act_8_1", actor: bohan, action: "created this issue", createdAt: "2026-03-17T10:00:00Z" },
|
||||
{ id: "act_8_2", actor: bohan, action: "assigned this to Codex-1", createdAt: "2026-03-17T10:01:00Z" },
|
||||
{ id: "act_8_3", actor: codex1, action: "moved this to In Progress", createdAt: "2026-03-19T08:00:00Z" },
|
||||
{ id: "act_8_4", actor: codex1, action: "moved this to In Review", createdAt: "2026-03-21T04:00:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-17T10:00:00Z",
|
||||
updatedAt: "2026-03-21T04:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "iss_11",
|
||||
key: "MUL-11",
|
||||
title: "Implement inbox notification API",
|
||||
description: "Build REST endpoints for inbox CRUD — list, mark read, archive. Include filtering by severity and type.",
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
assignee: claude1,
|
||||
creator: jiayuan,
|
||||
dueDate: "2026-03-22T00:00:00Z",
|
||||
comments: [
|
||||
{
|
||||
id: "cmt_11_1",
|
||||
author: claude1,
|
||||
body: "PR #48 is ready. All tests pass, including the new integration tests for batch mark-as-read.",
|
||||
createdAt: "2026-03-21T02:00:00Z",
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{ id: "act_11_1", actor: jiayuan, action: "created this issue", createdAt: "2026-03-18T14:00:00Z" },
|
||||
{ id: "act_11_2", actor: jiayuan, action: "assigned this to Claude-1", createdAt: "2026-03-18T14:00:00Z" },
|
||||
{ id: "act_11_3", actor: claude1, action: "moved this to In Review", createdAt: "2026-03-21T02:00:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-18T14:00:00Z",
|
||||
updatedAt: "2026-03-21T02:00:00Z",
|
||||
},
|
||||
|
||||
// ---- Done ----
|
||||
{
|
||||
id: "iss_3",
|
||||
key: "MUL-3",
|
||||
title: "Set up CI/CD pipeline",
|
||||
description: "Configure GitHub Actions for build, test, and lint on every push to main.",
|
||||
status: "done",
|
||||
priority: "high",
|
||||
assignee: bohan,
|
||||
creator: bohan,
|
||||
dueDate: "2026-03-18T00:00:00Z",
|
||||
comments: [],
|
||||
activity: [
|
||||
{ id: "act_3_1", actor: bohan, action: "created this issue", createdAt: "2026-03-15T09:00:00Z" },
|
||||
{ id: "act_3_2", actor: bohan, action: "moved this to Done", createdAt: "2026-03-20T15:00:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-15T09:00:00Z",
|
||||
updatedAt: "2026-03-20T15:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "iss_6",
|
||||
key: "MUL-6",
|
||||
title: "Standardize API error handling",
|
||||
description: "Create a consistent error response format across all API endpoints. Add error codes enum and panic recovery middleware.",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
assignee: claude1,
|
||||
creator: jiayuan,
|
||||
dueDate: "2026-03-20T00:00:00Z",
|
||||
comments: [
|
||||
{
|
||||
id: "cmt_6_1",
|
||||
author: claude1,
|
||||
body: "All acceptance criteria passed. PR #45 created and CI is green.",
|
||||
createdAt: "2026-03-20T22:10:00Z",
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{ id: "act_6_1", actor: jiayuan, action: "created this issue", createdAt: "2026-03-16T10:00:00Z" },
|
||||
{ id: "act_6_2", actor: jiayuan, action: "assigned this to Claude-1", createdAt: "2026-03-16T10:00:00Z" },
|
||||
{ id: "act_6_3", actor: claude1, action: "moved this to Done", createdAt: "2026-03-20T22:10:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-16T10:00:00Z",
|
||||
updatedAt: "2026-03-20T22:10:00Z",
|
||||
},
|
||||
{
|
||||
id: "iss_1",
|
||||
key: "MUL-1",
|
||||
title: "Initialize monorepo structure",
|
||||
description: "Set up the polyglot monorepo with Go backend, Next.js frontend, and shared TypeScript packages.",
|
||||
status: "done",
|
||||
priority: "urgent",
|
||||
assignee: jiayuan,
|
||||
creator: jiayuan,
|
||||
dueDate: "2026-03-15T00:00:00Z",
|
||||
comments: [],
|
||||
activity: [
|
||||
{ id: "act_1_1", actor: jiayuan, action: "created this issue", createdAt: "2026-03-14T08:00:00Z" },
|
||||
{ id: "act_1_2", actor: jiayuan, action: "moved this to Done", createdAt: "2026-03-15T18:00:00Z" },
|
||||
],
|
||||
createdAt: "2026-03-14T08:00:00Z",
|
||||
updatedAt: "2026-03-15T18:00:00Z",
|
||||
},
|
||||
];
|
||||
|
|
@ -1,15 +1,462 @@
|
|||
export default function IssuesPage() {
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Columns3,
|
||||
List,
|
||||
Plus,
|
||||
Bot,
|
||||
Circle,
|
||||
CircleDashed,
|
||||
CircleDot,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
CircleAlert,
|
||||
Eye,
|
||||
Minus,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
useDroppable,
|
||||
closestCorners,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} 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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared icon components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_ICONS: Record<IssueStatus, typeof Circle> = {
|
||||
backlog: CircleDashed,
|
||||
todo: Circle,
|
||||
in_progress: CircleDot,
|
||||
in_review: Eye,
|
||||
done: CircleCheck,
|
||||
blocked: CircleAlert,
|
||||
cancelled: CircleX,
|
||||
};
|
||||
|
||||
export function StatusIcon({
|
||||
status,
|
||||
className = "h-4 w-4",
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = STATUS_ICONS[status];
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
return <Icon className={`${className} ${cfg.iconColor}`} />;
|
||||
}
|
||||
|
||||
export function PriorityIcon({
|
||||
priority,
|
||||
className = "",
|
||||
}: {
|
||||
priority: IssuePriority;
|
||||
className?: string;
|
||||
}) {
|
||||
const cfg = PRIORITY_CONFIG[priority];
|
||||
if (cfg.bars === 0) {
|
||||
return <Minus className={`h-3.5 w-3.5 text-muted-foreground ${className}`} />;
|
||||
}
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Issues</h1>
|
||||
<button className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground">
|
||||
New Issue
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
No issues yet. Create your first issue to get started.
|
||||
</p>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={`h-3.5 w-3.5 ${cfg.color} ${className}`}
|
||||
fill="currentColor"
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={1 + i * 4}
|
||||
y={12 - (i + 1) * 3}
|
||||
width="3"
|
||||
height={(i + 1) * 3}
|
||||
rx="0.5"
|
||||
opacity={i < cfg.bars ? 1 : 0.2}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneeAvatar({
|
||||
assignee,
|
||||
size = "sm",
|
||||
}: {
|
||||
assignee: MockAssignee | null;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
if (!assignee) return null;
|
||||
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"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
title={assignee.name}
|
||||
>
|
||||
{assignee.type === "agent" ? (
|
||||
<Bot className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
|
||||
) : (
|
||||
assignee.avatar.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board View — Card (static, used in both draggable wrapper and overlay)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BoardCardContent({ issue }: { issue: MockIssue }) {
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
{issue.dueDate && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(issue.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draggable card wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DraggableBoardCard({ issue }: { issue: MockIssue }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: issue.id,
|
||||
data: { status: issue.status },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={isDragging ? "opacity-30" : ""}
|
||||
>
|
||||
<Link
|
||||
href={`/issues/${issue.id}`}
|
||||
onClick={(e) => {
|
||||
// Prevent navigation when dragging
|
||||
if (isDragging) e.preventDefault();
|
||||
}}
|
||||
className="block transition-colors hover:opacity-80"
|
||||
>
|
||||
<BoardCardContent issue={issue} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Droppable column
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DroppableColumn({
|
||||
status,
|
||||
issues,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issues: MockIssue[];
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
|
||||
return (
|
||||
<div className="flex w-64 shrink-0 flex-col">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">{cfg.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{issues.length}</span>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex-1 space-y-1.5 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver ? "bg-accent/40" : ""
|
||||
}`}
|
||||
>
|
||||
{issues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board View (with DnD)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BoardView({
|
||||
issues,
|
||||
onMoveIssue,
|
||||
}: {
|
||||
issues: MockIssue[];
|
||||
onMoveIssue: (issueId: string, newStatus: IssueStatus) => void;
|
||||
}) {
|
||||
const [activeIssue, setActiveIssue] = useState<MockIssue | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
})
|
||||
);
|
||||
|
||||
const visibleStatuses: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
];
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const issue = issues.find((i) => i.id === event.active.id);
|
||||
if (issue) setActiveIssue(issue);
|
||||
},
|
||||
[issues]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveIssue(null);
|
||||
const { active, over } = event;
|
||||
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;
|
||||
}
|
||||
|
||||
if (targetStatus) {
|
||||
const currentIssue = issues.find((i) => i.id === issueId);
|
||||
if (currentIssue && currentIssue.status !== targetStatus) {
|
||||
onMoveIssue(issueId, targetStatus);
|
||||
}
|
||||
}
|
||||
},
|
||||
[issues, onMoveIssue, visibleStatuses]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex h-full gap-3 overflow-x-auto p-4">
|
||||
{visibleStatuses.map((status) => (
|
||||
<DroppableColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issues={issues.filter((i) => i.status === status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeIssue ? (
|
||||
<div className="w-64 rotate-2 opacity-90 shadow-lg">
|
||||
<BoardCardContent issue={activeIssue} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ListRow({ issue }: { issue: MockIssue }) {
|
||||
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>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
|
||||
{issue.dueDate && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDate(issue.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
<AssigneeAvatar assignee={issue.assignee} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ListView({ issues }: { issues: MockIssue[] }) {
|
||||
const groupOrder: IssueStatus[] = [
|
||||
"in_review",
|
||||
"in_progress",
|
||||
"todo",
|
||||
"backlog",
|
||||
"done",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
{groupOrder.map((status) => {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const filtered = issues.filter((i) => i.status === status);
|
||||
if (filtered.length === 0) return null;
|
||||
return (
|
||||
<div key={status}>
|
||||
<div className="flex h-8 items-center gap-2 border-b px-4">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">{cfg.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{filtered.length}</span>
|
||||
</div>
|
||||
{filtered.map((issue) => (
|
||||
<ListRow key={issue.id} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ViewMode = "board" | "list";
|
||||
|
||||
export default function IssuesPage() {
|
||||
const [view, setView] = useState<ViewMode>("board");
|
||||
const [issues, setIssues] = useState<MockIssue[]>(MOCK_ISSUES);
|
||||
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus) => {
|
||||
setIssues((prev) =>
|
||||
prev.map((issue) =>
|
||||
issue.id === issueId
|
||||
? { ...issue, status: newStatus, updatedAt: new Date().toISOString() }
|
||||
: issue
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-semibold">All Issues</h1>
|
||||
<div className="ml-2 flex items-center rounded-md border p-0.5">
|
||||
<button
|
||||
onClick={() => setView("board")}
|
||||
className={`flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
|
||||
view === "board"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Columns3 className="h-3 w-3" />
|
||||
Board
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={`flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
|
||||
view === "list"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<List className="h-3 w-3" />
|
||||
List
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{view === "board" ? (
|
||||
<BoardView issues={issues} onMoveIssue={handleMoveIssue} />
|
||||
) : (
|
||||
<ListView issues={issues} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
282
apps/web/app/(dashboard)/knowledge-base/_data/mock.ts
Normal file
282
apps/web/app/(dashboard)/knowledge-base/_data/mock.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
export interface KBDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
createdBy: string;
|
||||
updatedAt: string;
|
||||
referencedBy: string[];
|
||||
}
|
||||
|
||||
export const MOCK_DOCUMENTS: KBDocument[] = [
|
||||
{
|
||||
id: "kb_1",
|
||||
title: "Product Vision & Positioning",
|
||||
content: `Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
|
||||
## Target Users
|
||||
|
||||
- 2–10 person technical teams / startups
|
||||
- Teams that already use AI coding agents daily (Claude Code, Codex, etc.)
|
||||
- Current toolchain: Linear/GitHub Issues + Claude Code/Codex + GitHub + IM
|
||||
|
||||
## Core Value Proposition
|
||||
|
||||
| Dimension | Existing Tools (Linear) | Multica |
|
||||
|-----------|------------------------|---------|
|
||||
| Task executor | Humans only | Humans + Agents |
|
||||
| Context | Manual copy-paste | Auto-aggregated, agents read directly |
|
||||
| Task flow | Human-driven status changes | Agents auto-flow + notify humans when decisions needed |
|
||||
| Concurrency | Limited by team size | Parallel agent dispatch |
|
||||
|
||||
## What We Are NOT
|
||||
|
||||
- Not a general-purpose project management tool (no Gantt charts, resource planning)
|
||||
- Not an AI agent framework (we orchestrate, not build agents)
|
||||
- Not a chat product (Inbox is action-oriented, not conversational)`,
|
||||
createdBy: "Jiayuan",
|
||||
updatedAt: "2026-03-20T10:00:00Z",
|
||||
referencedBy: ["MUL-1"],
|
||||
},
|
||||
{
|
||||
id: "kb_2",
|
||||
title: "Architecture Overview",
|
||||
content: `## System Architecture
|
||||
|
||||
Polyglot monorepo — Go backend + TypeScript frontend.
|
||||
|
||||
\`\`\`
|
||||
server/ — Go backend (Chi + sqlc + gorilla/websocket)
|
||||
apps/web/ — Next.js 16 frontend
|
||||
packages/ — Shared TypeScript packages (ui, types, sdk, store, hooks, utils)
|
||||
\`\`\`
|
||||
|
||||
## Backend
|
||||
|
||||
- **HTTP Framework**: Chi router
|
||||
- **Database**: PostgreSQL 17 with pgvector extension
|
||||
- **ORM/Query**: sqlc (type-safe SQL → Go code generation)
|
||||
- **WebSocket**: gorilla/websocket for real-time agent status updates
|
||||
- **Auth**: Google OAuth with JWT tokens
|
||||
|
||||
## Frontend
|
||||
|
||||
- **Framework**: Next.js 16 (App Router)
|
||||
- **UI Components**: shadcn/ui (Radix + Tailwind CSS v4)
|
||||
- **State**: Zustand
|
||||
- **Styling**: Tailwind CSS v4, OKLCH color system
|
||||
|
||||
## Agent Communication
|
||||
|
||||
\`\`\`
|
||||
Server ←→ WebSocket Gateway ←→ Daemon (local machine)
|
||||
↓
|
||||
Claude Code / Codex / etc.
|
||||
\`\`\`
|
||||
|
||||
The Daemon is a local process that maintains a persistent WebSocket connection to the server, receives task assignments, and delegates to the underlying AI runtime.`,
|
||||
createdBy: "Jiayuan",
|
||||
updatedAt: "2026-03-19T14:00:00Z",
|
||||
referencedBy: ["MUL-9", "MUL-14"],
|
||||
},
|
||||
{
|
||||
id: "kb_3",
|
||||
title: "API Error Handling Convention",
|
||||
content: `## Error Response Format
|
||||
|
||||
All API errors follow this structure:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"error": {
|
||||
"code": "ISSUE_NOT_FOUND",
|
||||
"message": "Issue with id 'iss_999' not found",
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | HTTP Status | Description |
|
||||
|------|-------------|-------------|
|
||||
| VALIDATION_ERROR | 400 | Request body/params validation failed |
|
||||
| UNAUTHORIZED | 401 | Missing or invalid auth token |
|
||||
| FORBIDDEN | 403 | Valid token but insufficient permissions |
|
||||
| NOT_FOUND | 404 | Resource does not exist |
|
||||
| CONFLICT | 409 | State conflict (e.g. duplicate) |
|
||||
| INTERNAL_ERROR | 500 | Unhandled server error |
|
||||
|
||||
## Panic Recovery
|
||||
|
||||
The server uses a recovery middleware that catches panics, logs the stack trace, and returns a 500 with \`INTERNAL_ERROR\`. Never expose stack traces to clients.`,
|
||||
createdBy: "Claude-1",
|
||||
updatedAt: "2026-03-20T22:00:00Z",
|
||||
referencedBy: ["MUL-6"],
|
||||
},
|
||||
{
|
||||
id: "kb_4",
|
||||
title: "Agent Onboarding Guide",
|
||||
content: `## How Agents Work in Multica
|
||||
|
||||
When an issue is assigned to an agent, the following happens:
|
||||
|
||||
1. Server pushes the task to the agent's inbox via WebSocket
|
||||
2. Daemon receives the task and reads the issue context
|
||||
3. Agent retrieves relevant Knowledge Base documents
|
||||
4. Agent executes via the underlying runtime (Claude Code, Codex, etc.)
|
||||
5. Progress is reported back through WebSocket status updates
|
||||
6. On completion, agent creates a PR and moves the issue to "In Review"
|
||||
|
||||
## Agent Capabilities
|
||||
|
||||
- Receive and execute issues
|
||||
- Create new issues (when blocked or discovering sub-tasks)
|
||||
- Comment on issues (progress updates, questions)
|
||||
- Change issue status
|
||||
- Read Knowledge Base documents
|
||||
- Create branches, commits, and pull requests
|
||||
|
||||
## When Agents Get Blocked
|
||||
|
||||
If an agent cannot proceed, it should:
|
||||
1. Change the issue status to "Blocked"
|
||||
2. Leave a comment explaining what it needs
|
||||
3. Create an inbox notification for the assignee with severity "action_required"
|
||||
4. Wait for human input before continuing`,
|
||||
createdBy: "Bohan",
|
||||
updatedAt: "2026-03-19T16:00:00Z",
|
||||
referencedBy: ["MUL-12", "MUL-15"],
|
||||
},
|
||||
{
|
||||
id: "kb_5",
|
||||
title: "Database Schema: Issues",
|
||||
content: `## Issues Table
|
||||
|
||||
\`\`\`sql
|
||||
CREATE TABLE issues (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_nanoid(),
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT NOT NULL DEFAULT 'none',
|
||||
assignee_type TEXT, -- 'member' | 'agent'
|
||||
assignee_id TEXT,
|
||||
creator_type TEXT NOT NULL,
|
||||
creator_id TEXT NOT NULL,
|
||||
parent_issue_id TEXT REFERENCES issues(id),
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
due_date TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
\`\`\`
|
||||
|
||||
## Indexes
|
||||
|
||||
\`\`\`sql
|
||||
CREATE INDEX idx_issues_workspace_status ON issues(workspace_id, status);
|
||||
CREATE INDEX idx_issues_assignee ON issues(assignee_type, assignee_id);
|
||||
CREATE INDEX idx_issues_workspace_created ON issues(workspace_id, created_at);
|
||||
\`\`\`
|
||||
|
||||
## Status Values
|
||||
|
||||
\`backlog\`, \`todo\`, \`in_progress\`, \`in_review\`, \`done\`, \`blocked\`, \`cancelled\`
|
||||
|
||||
## Priority Values
|
||||
|
||||
\`urgent\`, \`high\`, \`medium\`, \`low\`, \`none\``,
|
||||
createdBy: "Yuzhen",
|
||||
updatedAt: "2026-03-18T11:00:00Z",
|
||||
referencedBy: ["MUL-9"],
|
||||
},
|
||||
{
|
||||
id: "kb_6",
|
||||
title: "PR & Code Review Guidelines",
|
||||
content: `## Branch Naming
|
||||
|
||||
\`\`\`
|
||||
feat/short-description
|
||||
fix/short-description
|
||||
refactor/short-description
|
||||
\`\`\`
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Follow conventional commits:
|
||||
|
||||
\`\`\`
|
||||
feat(scope): add new feature
|
||||
fix(scope): fix a bug
|
||||
refactor(scope): code restructure
|
||||
docs: documentation only
|
||||
test(scope): add or update tests
|
||||
chore(scope): maintenance
|
||||
\`\`\`
|
||||
|
||||
## PR Requirements
|
||||
|
||||
- Keep PRs small and focused (< 400 lines when possible)
|
||||
- Include a description of what and why
|
||||
- All CI checks must pass
|
||||
- At least one human review required for agent-authored PRs
|
||||
- Agent PRs are auto-labeled with \`agent-authored\`
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] Does the code do what the issue describes?
|
||||
- [ ] Are there tests for new behavior?
|
||||
- [ ] No security vulnerabilities introduced
|
||||
- [ ] No hardcoded secrets or credentials
|
||||
- [ ] Error handling is appropriate`,
|
||||
createdBy: "Bohan",
|
||||
updatedAt: "2026-03-17T09:00:00Z",
|
||||
referencedBy: ["MUL-3", "MUL-8"],
|
||||
},
|
||||
{
|
||||
id: "kb_7",
|
||||
title: "WebSocket Protocol",
|
||||
content: `## Connection
|
||||
|
||||
\`\`\`
|
||||
ws://localhost:8080/ws?token=<jwt>
|
||||
\`\`\`
|
||||
|
||||
## Message Format
|
||||
|
||||
All messages use JSON:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "agent.status_update",
|
||||
"payload": { ... },
|
||||
"timestamp": "2026-03-20T10:00:00Z"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Event Types
|
||||
|
||||
### Server → Client
|
||||
- \`agent.status_update\` — Agent status changed (idle/working/blocked/error/offline)
|
||||
- \`issue.updated\` — Issue fields changed
|
||||
- \`inbox.new_item\` — New inbox notification
|
||||
- \`task.progress\` — Agent reports execution progress
|
||||
|
||||
### Client → Server
|
||||
- \`agent.heartbeat\` — Daemon sends periodic heartbeat
|
||||
- \`task.started\` — Agent began executing a task
|
||||
- \`task.completed\` — Agent finished a task
|
||||
- \`task.failed\` — Agent failed a task
|
||||
|
||||
## Reconnection
|
||||
|
||||
Daemon uses exponential backoff with jitter:
|
||||
- Initial delay: 1s
|
||||
- Max delay: 30s
|
||||
- Jitter: ±25%`,
|
||||
createdBy: "Bohan",
|
||||
updatedAt: "2026-03-21T04:00:00Z",
|
||||
referencedBy: ["MUL-8", "MUL-14"],
|
||||
},
|
||||
];
|
||||
337
apps/web/app/(dashboard)/knowledge-base/page.tsx
Normal file
337
apps/web/app/(dashboard)/knowledge-base/page.tsx
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Search,
|
||||
Link as LinkIcon,
|
||||
} from "lucide-react";
|
||||
import { MOCK_DOCUMENTS, type KBDocument } from "./_data/mock";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple Markdown-ish renderer (handles headers, code blocks, tables, lists)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderMarkdown(text: string): React.ReactNode[] {
|
||||
const lines = text.split("\n");
|
||||
const elements: React.ReactNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Code block
|
||||
if (line.startsWith("```")) {
|
||||
const lang = line.slice(3).trim();
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // skip closing ```
|
||||
elements.push(
|
||||
<pre
|
||||
key={`code-${i}`}
|
||||
className="my-3 overflow-x-auto rounded-md bg-muted px-4 py-3 text-[13px] leading-relaxed"
|
||||
>
|
||||
<code>{codeLines.join("\n")}</code>
|
||||
</pre>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table (simplified: detect | pipes)
|
||||
if (line.includes("|") && line.trim().startsWith("|")) {
|
||||
const tableRows: string[] = [];
|
||||
while (i < lines.length && lines[i].includes("|") && lines[i].trim().startsWith("|")) {
|
||||
tableRows.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
// Filter out separator rows (|---|---|)
|
||||
const dataRows = tableRows.filter((r) => !r.match(/^\|[\s-|]+\|$/));
|
||||
if (dataRows.length > 0) {
|
||||
const parseRow = (row: string) =>
|
||||
row.split("|").filter((c) => c.trim() !== "").map((c) => c.trim());
|
||||
const header = parseRow(dataRows[0]);
|
||||
const body = dataRows.slice(1).map(parseRow);
|
||||
elements.push(
|
||||
<div key={`table-${i}`} className="my-3 overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{header.map((h, hi) => (
|
||||
<th key={hi} className="py-1.5 pr-4 text-left font-medium">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, ri) => (
|
||||
<tr key={ri} className="border-b last:border-0">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="py-1.5 pr-4 text-foreground/80">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading
|
||||
if (line.startsWith("## ")) {
|
||||
elements.push(
|
||||
<h2 key={`h2-${i}`} className="mt-6 mb-2 text-[15px] font-semibold">
|
||||
{line.slice(3)}
|
||||
</h2>
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("### ")) {
|
||||
elements.push(
|
||||
<h3 key={`h3-${i}`} className="mt-4 mb-1.5 text-[14px] font-medium">
|
||||
{line.slice(4)}
|
||||
</h3>
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// List item
|
||||
if (line.match(/^- \[[ x]\] /)) {
|
||||
const checked = line.includes("[x]");
|
||||
const text = line.replace(/^- \[[ x]\] /, "");
|
||||
elements.push(
|
||||
<div key={`check-${i}`} className="flex items-center gap-2 py-0.5 text-[13px] text-foreground/80">
|
||||
<input type="checkbox" checked={checked} readOnly className="h-3.5 w-3.5 rounded" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("- ")) {
|
||||
elements.push(
|
||||
<div key={`li-${i}`} className="flex gap-2 py-0.5 text-[13px] text-foreground/80">
|
||||
<span className="mt-[7px] h-1 w-1 shrink-0 rounded-full bg-foreground/40" />
|
||||
<span>{renderInline(line.slice(2))}</span>
|
||||
</div>
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Numbered list
|
||||
if (line.match(/^\d+\. /)) {
|
||||
const num = line.match(/^(\d+)\. /)![1];
|
||||
const text = line.replace(/^\d+\. /, "");
|
||||
elements.push(
|
||||
<div key={`ol-${i}`} className="flex gap-2 py-0.5 text-[13px] text-foreground/80">
|
||||
<span className="w-4 shrink-0 text-right text-muted-foreground">{num}.</span>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (line.trim() === "") {
|
||||
elements.push(<div key={`br-${i}`} className="h-2" />);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
elements.push(
|
||||
<p key={`p-${i}`} className="text-[13px] leading-[1.7] text-foreground/85">
|
||||
{renderInline(line)}
|
||||
</p>
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function renderInline(text: string): React.ReactNode {
|
||||
// Handle inline code `...`
|
||||
const parts = text.split(/(`[^`]+`)/);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith("`") && part.endsWith("`")) {
|
||||
return (
|
||||
<code key={i} className="rounded bg-muted px-1 py-0.5 text-[12px]">
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DocListItem({
|
||||
doc,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
doc: KBDocument;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-start gap-2.5 px-4 py-2.5 text-left transition-colors ${
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium">{doc.title}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{doc.createdBy}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(doc.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DocDetail({ doc }: { doc: KBDocument }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-3xl px-8 py-8">
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-semibold tracking-tight">{doc.title}</h1>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="mt-2 flex items-center gap-3 text-[12px] text-muted-foreground">
|
||||
<span>By {doc.createdBy}</span>
|
||||
<span>·</span>
|
||||
<span>Updated {timeAgo(doc.updatedAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-6">{renderMarkdown(doc.content)}</div>
|
||||
|
||||
{/* Referenced by */}
|
||||
{doc.referencedBy.length > 0 && (
|
||||
<div className="mt-10 border-t pt-4">
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Referenced by</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{doc.referencedBy.map((ref) => (
|
||||
<span
|
||||
key={ref}
|
||||
className="rounded bg-muted px-2 py-0.5 text-[12px] font-mono"
|
||||
>
|
||||
{ref}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const [selectedId, setSelectedId] = useState<string>(
|
||||
MOCK_DOCUMENTS[0]?.id ?? ""
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = search
|
||||
? MOCK_DOCUMENTS.filter((d) =>
|
||||
d.title.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: MOCK_DOCUMENTS;
|
||||
|
||||
const selected = MOCK_DOCUMENTS.find((d) => d.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left: Document list */}
|
||||
<div className="w-72 shrink-0 overflow-y-auto border-r">
|
||||
<div className="flex h-11 items-center justify-between border-b px-4">
|
||||
<h1 className="text-sm font-semibold">Knowledge Base</h1>
|
||||
<button className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-accent">
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2 rounded-md border bg-background px-2.5 py-1.5">
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search docs..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document list */}
|
||||
<div className="divide-y">
|
||||
{filtered.map((doc) => (
|
||||
<DocListItem
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
isSelected={doc.id === selectedId}
|
||||
onClick={() => setSelectedId(doc.id)}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
||||
No documents found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Document content */}
|
||||
{selected ? (
|
||||
<DocDetail doc={selected} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
Select a document
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,16 +6,16 @@ import {
|
|||
Inbox,
|
||||
ListTodo,
|
||||
Bot,
|
||||
Columns3,
|
||||
Settings,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { MulticaIcon } from "@multica/ui/components/multica-icon";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
{ href: "/issues", label: "Issues", icon: ListTodo },
|
||||
{ href: "/board", label: "Board", icon: Columns3 },
|
||||
{ href: "/agents", label: "Agents", icon: Bot },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
{ href: "/issues", label: "Issues", icon: ListTodo },
|
||||
{ href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen },
|
||||
];
|
||||
|
||||
export default function DashboardLayout({
|
||||
|
|
@ -26,26 +26,34 @@ export default function DashboardLayout({
|
|||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Sidebar */}
|
||||
<aside className="flex w-56 flex-col border-r bg-card">
|
||||
<div className="flex h-14 items-center gap-2 border-b px-4">
|
||||
<span className="text-lg font-bold">Multica</span>
|
||||
<div className="flex h-screen bg-canvas">
|
||||
{/* Sidebar — sits on the canvas layer */}
|
||||
<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>
|
||||
<nav className="flex-1 space-y-1 p-2">
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-0.5 px-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors ${
|
||||
className={`flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50"
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
|
|
@ -53,8 +61,12 @@ export default function DashboardLayout({
|
|||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
{/* Main content — floating panel on top of the canvas */}
|
||||
<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}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@multica/hooks": "workspace:*",
|
||||
"@multica/sdk": "workspace:*",
|
||||
"@multica/store": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@
|
|||
"devDependencies": {
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"shadcn": "^4.1.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@
|
|||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-brand: var(--font-brand);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
|
@ -156,7 +157,8 @@
|
|||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--canvas: oklch(0.95 0.002 286);
|
||||
--sidebar: oklch(0.95 0.002 286);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.205 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
|
|
@ -204,7 +206,8 @@
|
|||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--canvas: oklch(0.2 0.005 286);
|
||||
--sidebar: oklch(0.2 0.005 286);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.922 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.205 0.006 285.885);
|
||||
|
|
|
|||
2682
pnpm-lock.yaml
generated
2682
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue