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:
Jiayuan Zhang 2026-03-21 14:53:32 +08:00 committed by GitHub
commit d75746021f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 5369 additions and 49 deletions

98
README.md Normal file
View 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

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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",
},
];

View file

@ -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>
);
}

View 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
- 210 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"],
},
];

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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:*",

View file

@ -43,6 +43,8 @@
"devDependencies": {
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"shadcn": "^4.1.0",
"tw-animate-css": "^1.4.0",
"typescript": "catalog:"
}
}

View file

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

File diff suppressed because it is too large Load diff