Merge pull request #245 from multica-ai/feature/issues-ui-ux-optimization

refactor(web): unify design system with shadcn components
This commit is contained in:
Naiyuan Qing 2026-03-24 14:19:16 +08:00 committed by GitHub
commit 4bab6f71fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 613 additions and 512 deletions

View file

@ -62,6 +62,9 @@ docker compose down # Stop PostgreSQL
## 5. UI/UX Rules
- Prefer `packages/ui` shadcn components over custom implementations.
- **shadcn official components**`packages/ui/src/components/ui/` — keep this directory clean; install missing components via `npx shadcn add`, do not mix in business code.
- **Shared business components & utils**`packages/ui/src/components/common/` — reusable project-level UI components (e.g. StatusBadge, PriorityIcon) and shared utilities live here.
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
- When unsure about interaction or state design, ask — the user will provide direction.

View file

@ -44,9 +44,9 @@ describe("LoginPage", () => {
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Name")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign in" })).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByLabelText("Name")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
});
it("does not call login when email is empty", async () => {
@ -64,8 +64,8 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
await user.type(screen.getByPlaceholderText("Name"), "Test User");
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.type(screen.getByLabelText("Name"), "Test User");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
@ -78,7 +78,7 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
@ -92,7 +92,7 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
@ -105,7 +105,7 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {

View file

@ -3,6 +3,17 @@
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "../../../lib/auth-context";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@multica/ui/components/ui/card";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import { Label } from "@multica/ui/components/ui/label";
function LoginPageContent() {
const { login, isLoading } = useAuth();
@ -30,38 +41,51 @@ function LoginPageContent() {
return (
<div className="flex min-h-screen items-center justify-center">
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4 text-center">
<h1 className="text-2xl font-bold">Multica</h1>
<p className="text-muted-foreground">AI-native task management</p>
<div className="space-y-3 text-left">
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
type="submit"
disabled={submitting || isLoading}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
>
{submitting ? "Signing in..." : "Sign in"}
</button>
</form>
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Multica</CardTitle>
<CardDescription>AI-native task management</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</form>
</CardContent>
<CardFooter>
<Button
type="submit"
form="login-form"
disabled={submitting || isLoading}
className="w-full"
size="lg"
>
{submitting ? "Signing in..." : "Sign in"}
</Button>
</CardFooter>
</Card>
</div>
);
}

View file

@ -26,6 +26,17 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@multica/ui/components/ui/sidebar";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@multica/ui/components/ui/dialog";
import { useAuth } from "../../../lib/auth-context";
import { useTabStore } from "../../../lib/tab-store";
@ -165,7 +176,7 @@ export function AppSidebar() {
setShowMenu(false);
logout();
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive hover:bg-accent"
>
<LogOut className="h-3.5 w-3.5" />
Sign out
@ -230,66 +241,57 @@ export function AppSidebar() {
</Sidebar>
{/* Create Workspace Dialog */}
{showCreateDialog && (
<>
<div
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
onClick={() => setShowCreateDialog(false)}
/>
<div className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
<div className="flex flex-col gap-1.5">
<h2 className="text-lg font-semibold leading-none">
Create workspace
</h2>
<p className="text-sm text-muted-foreground">
Create a new workspace for your team.
</p>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create workspace</DialogTitle>
<DialogDescription>
Create a new workspace for your team.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">
Name
</Label>
<Input
autoFocus
type="text"
value={newName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
className="mt-1"
/>
</div>
<div className="mt-4 space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">
Name
</label>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
Slug
</label>
<input
type="text"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
placeholder="my-workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setShowCreateDialog(false)}
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleCreateWorkspace}
disabled={creating || !newName.trim() || !newSlug.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? "Creating..." : "Create"}
</button>
<div>
<Label className="text-xs text-muted-foreground">
Slug
</Label>
<Input
type="text"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
placeholder="my-workspace"
className="mt-1"
/>
</div>
</div>
</>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowCreateDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleCreateWorkspace}
disabled={creating || !newName.trim() || !newSlug.trim()}
>
{creating ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -12,7 +12,6 @@ import {
Timer,
Trash2,
Save,
X,
Key,
Link2,
Clock,
@ -34,6 +33,18 @@ import type {
CreateAgentRequest,
UpdateAgentRequest,
} from "@multica/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@multica/ui/components/ui/dialog";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { Label } from "@multica/ui/components/ui/label";
import { api } from "../../../lib/api";
import { useAuth } from "../../../lib/auth-context";
import { useWSEvent } from "../../../lib/ws-context";
@ -44,18 +55,18 @@ import { useWSEvent } from "../../../lib/ws-context";
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" },
working: { label: "Working", color: "text-success", dot: "bg-success" },
blocked: { label: "Blocked", color: "text-warning", dot: "bg-warning" },
error: { label: "Error", color: "text-destructive", dot: "bg-destructive" },
offline: { label: "Offline", color: "text-muted-foreground/50", dot: "bg-muted-foreground/40" },
};
const taskStatusConfig: Record<string, { label: string; icon: typeof CheckCircle2; color: string }> = {
queued: { label: "Queued", icon: Clock, color: "text-muted-foreground" },
dispatched: { label: "Dispatched", icon: Play, color: "text-blue-500" },
running: { label: "Running", icon: Loader2, color: "text-green-500" },
completed: { label: "Completed", icon: CheckCircle2, color: "text-green-600" },
failed: { label: "Failed", icon: XCircle, color: "text-red-500" },
dispatched: { label: "Dispatched", icon: Play, color: "text-info" },
running: { label: "Running", icon: Loader2, color: "text-success" },
completed: { label: "Completed", icon: CheckCircle2, color: "text-success" },
failed: { label: "Failed", icon: XCircle, color: "text-destructive" },
cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground" },
};
@ -120,55 +131,49 @@ function CreateAgentDialog({
};
return (
<>
<div
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
onClick={onClose}
/>
<div className="fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Create Agent</h2>
<button onClick={onClose} className="rounded-md p-1 hover:bg-accent">
<X className="h-4 w-4" />
</button>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Create a new AI agent for your workspace.
</p>
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Agent</DialogTitle>
<DialogDescription>
Create a new AI agent for your workspace.
</DialogDescription>
</DialogHeader>
<div className="mt-5 space-y-4">
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground">Name</label>
<input
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Deep Research Agent"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Description</label>
<input
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this agent do?"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
className="mt-1"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Runtime</label>
<Label className="text-xs text-muted-foreground">Runtime</Label>
<div className="relative mt-1.5">
<button
<Button
type="button"
variant="outline"
onClick={() => setRuntimeOpen(!runtimeOpen)}
disabled={runtimes.length === 0}
className="flex w-full items-center gap-3 rounded-md border bg-background px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent/50 focus:outline-none focus:ring-2 focus:ring-ring"
className="flex w-full items-center gap-3 px-3 py-2.5 h-auto text-left text-sm"
>
{selectedRuntime?.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
@ -181,7 +186,7 @@ function CreateAgentDialog({
{selectedRuntime?.name ?? "No runtime available"}
</span>
{selectedRuntime?.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600">
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-[10px] font-medium text-info">
Cloud
</span>
)}
@ -191,7 +196,7 @@ function CreateAgentDialog({
</div>
</div>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
</button>
</Button>
{runtimeOpen && (
<>
@ -217,7 +222,7 @@ function CreateAgentDialog({
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600">
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-[10px] font-medium text-info">
Cloud
</span>
)}
@ -226,7 +231,7 @@ function CreateAgentDialog({
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-green-500" : "bg-muted-foreground/40"
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</button>
@ -238,23 +243,19 @@ function CreateAgentDialog({
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</button>
<button
</Button>
<Button
onClick={handleSubmit}
disabled={creating || !name.trim() || !selectedRuntime}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
</div>
</>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@ -340,21 +341,21 @@ function SkillsTab({
</p>
</div>
{isDirty && (
<button
<Button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
size="xs"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</button>
</Button>
)}
</div>
<textarea
<Textarea
value={skills}
onChange={(e) => setSkills(e.target.value)}
placeholder={`# Agent Name\n\nDescribe what this agent does and how it should work.\n\n## Workflow\n1. Step one\n2. Step two\n3. Step three\n\n## Output Format\nDescribe the expected output...`}
className="h-96 w-full resize-none rounded-lg border bg-background px-4 py-3 font-mono text-sm leading-relaxed focus:outline-none focus:ring-2 focus:ring-ring"
className="h-96 resize-none font-mono leading-relaxed"
/>
</div>
);
@ -389,74 +390,73 @@ function AddToolDialog({
};
return (
<>
<div
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
onClick={onClose}
/>
<div className="fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
<h3 className="text-sm font-semibold">Add Tool</h3>
<p className="mt-1 text-xs text-muted-foreground">
Connect an external tool for this agent to use.
</p>
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">Add Tool</DialogTitle>
<DialogDescription className="text-xs">
Connect an external tool for this agent to use.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-3">
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Tool Name</label>
<input
<Label className="text-xs text-muted-foreground">Tool Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Google Search, Slack, GitHub"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Description</label>
<input
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this tool do?"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
className="mt-1"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Authentication</label>
<Label className="text-xs text-muted-foreground">Authentication</Label>
<div className="mt-1.5 flex gap-2">
{(["api_key", "oauth", "none"] as const).map((type) => (
<button
<Button
key={type}
variant={authType === type ? "outline" : "ghost"}
size="xs"
onClick={() => setAuthType(type)}
className={`flex-1 rounded-md border px-2 py-1.5 text-xs transition-colors ${
className={`flex-1 ${
authType === type
? "border-primary bg-primary/5 font-medium"
: "hover:bg-accent"
: ""
}`}
>
{type === "api_key" ? "API Key" : type === "oauth" ? "OAuth" : "None"}
</button>
</Button>
))}
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={onClose} className="rounded-md px-3 py-1.5 text-sm hover:bg-accent">
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</button>
<button
</Button>
<Button
onClick={handleAdd}
disabled={!name.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Add
</button>
</div>
</div>
</>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@ -507,22 +507,23 @@ function ToolsTab({
</div>
<div className="flex items-center gap-2">
{isDirty && (
<button
<Button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
size="xs"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</button>
</Button>
)}
<button
<Button
variant="outline"
size="xs"
onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
<Plus className="h-3 w-3" />
Add Tool
</button>
</Button>
</div>
</div>
@ -530,13 +531,14 @@ function ToolsTab({
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
<Wrench className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No tools configured</p>
<button
<Button
onClick={() => setShowAdd(true)}
className="mt-3 flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Add Tool
</button>
</Button>
</div>
) : (
<div className="space-y-2">
@ -563,22 +565,26 @@ function ToolsTab({
)}
</div>
<div className="flex items-center gap-2">
<button
<Button
variant="ghost"
size="xs"
onClick={() => toggleConnect(tool.id)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
className={
tool.connected
? "bg-green-500/10 text-green-600"
? "bg-success/10 text-success"
: "bg-muted text-muted-foreground hover:bg-accent"
}`}
}
>
{tool.connected ? "Connected" : "Connect"}
</button>
<button
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeTool(tool.id)}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-red-500"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</Button>
</div>
</div>
))}
@ -661,14 +667,14 @@ function TriggersTab({
</div>
<div className="flex items-center gap-2">
{isDirty && (
<button
<Button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
size="xs"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</button>
</Button>
)}
</div>
</div>
@ -710,22 +716,24 @@ function TriggersTab({
}`}
/>
</button>
<button
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeTrigger(trigger.id)}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-red-500"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</Button>
</div>
</div>
{trigger.type === "scheduled" && (
<div className="mt-3 grid grid-cols-2 gap-3 pl-12">
<div>
<label className="text-xs font-medium text-muted-foreground">
<Label className="text-xs text-muted-foreground">
Cron Expression
</label>
<input
</Label>
<Input
type="text"
value={(trigger.config as { cron?: string }).cron ?? ""}
onChange={(e) =>
@ -735,14 +743,14 @@ function TriggersTab({
})
}
placeholder="0 9 * * 1-5"
className="mt-1 w-full rounded-md border bg-background px-3 py-1.5 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-ring"
className="mt-1 text-xs font-mono"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
<Label className="text-xs text-muted-foreground">
Timezone
</label>
<input
</Label>
<Input
type="text"
value={(trigger.config as { timezone?: string }).timezone ?? ""}
onChange={(e) =>
@ -752,7 +760,7 @@ function TriggersTab({
})
}
placeholder="UTC"
className="mt-1 w-full rounded-md border bg-background px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
className="mt-1 text-xs"
/>
</div>
</div>
@ -762,20 +770,24 @@ function TriggersTab({
</div>
<div className="flex gap-2">
<button
<Button
variant="outline"
size="xs"
onClick={() => addTrigger("on_assign")}
className="flex items-center gap-1.5 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
className="border-dashed text-muted-foreground hover:text-foreground"
>
<Bot className="h-3 w-3" />
Add On Assign
</button>
<button
</Button>
<Button
variant="outline"
size="xs"
onClick={() => addTrigger("scheduled")}
className="flex items-center gap-1.5 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
className="border-dashed text-muted-foreground hover:text-foreground"
>
<Timer className="h-3 w-3" />
Add Scheduled
</button>
</Button>
</div>
</div>
);
@ -909,12 +921,13 @@ function AgentDetail({
)}
</div>
<div className="relative">
<button
<Button
variant="ghost"
size="icon-sm"
onClick={() => setShowMenu(!showMenu)}
className="rounded-md p-1.5 hover:bg-accent"
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</button>
</Button>
{showMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
@ -924,7 +937,7 @@ function AgentDetail({
setShowMenu(false);
setConfirmDelete(true);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive hover:bg-accent"
>
<Trash2 className="h-3.5 w-3.5" />
Delete Agent
@ -978,15 +991,11 @@ function AgentDetail({
{/* Delete Confirmation */}
{confirmDelete && (
<>
<div
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
onClick={() => setConfirmDelete(false)}
/>
<div className="fixed top-1/2 left-1/2 z-50 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
<DialogContent className="max-w-sm" showCloseButton={false}>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-500/10">
<AlertCircle className="h-5 w-5 text-red-500" />
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<div>
<h3 className="text-sm font-semibold">Delete agent?</h3>
@ -995,25 +1004,22 @@ function AgentDetail({
</p>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={() => setConfirmDelete(false)}
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</button>
<button
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirmDelete(false);
onDelete(agent.id);
}}
className="rounded-md bg-red-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-600"
>
Delete
</button>
</div>
</div>
</>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
@ -1090,24 +1096,26 @@ export default function AgentsPage() {
<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
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
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>
</Button>
</div>
{agents.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Bot className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No agents yet</p>
<button
<Button
onClick={() => setShowCreate(true)}
className="mt-3 flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</button>
</Button>
</div>
) : (
<div className="divide-y">
@ -1136,13 +1144,14 @@ export default function AgentsPage() {
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Bot className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select an agent to view details</p>
<button
<Button
onClick={() => setShowCreate(true)}
className="mt-3 flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</button>
</Button>
</div>
)}
</div>

View file

@ -11,6 +11,7 @@ import {
ArrowRightLeft,
} from "lucide-react";
import type { InboxItem, InboxItemType, InboxSeverity, InboxNewPayload } from "@multica/types";
import { Button } from "@multica/ui/components/ui/button";
import { api } from "../../../lib/api";
import { useWSEvent } from "../../../lib/ws-context";
@ -34,8 +35,8 @@ const typeIcons: Record<InboxItemType, typeof AlertCircle> = {
};
const severityColors: Record<InboxSeverity, string> = {
action_required: "text-red-500",
attention: "text-yellow-500",
action_required: "text-destructive",
attention: "text-warning",
info: "text-muted-foreground",
};
@ -124,12 +125,14 @@ function InboxDetail({
</div>
</div>
{!item.read && (
<button
<Button
variant="outline"
size="xs"
onClick={() => onMarkRead(item.id)}
className="shrink-0 rounded-md border px-2 py-1 text-xs hover:bg-accent"
className="shrink-0"
>
Mark read
</button>
</Button>
)}
</div>

View file

@ -4,7 +4,6 @@ import { use, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Bot,
ChevronRight,
GitBranch,
Link2,
@ -31,6 +30,9 @@ import {
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
import { StatusPicker, PriorityPicker, AssigneePicker } from "../_components";
import { api } from "../../../../lib/api";
@ -62,42 +64,6 @@ function shortDate(date: string | null): string {
});
}
// ---------------------------------------------------------------------------
// Avatar
// ---------------------------------------------------------------------------
function ActorAvatar({
actorType,
actorId,
size = 20,
}: {
actorType: string;
actorId: string;
size?: number;
}) {
const { getActorName, getActorInitials } = useAuth();
const name = getActorName(actorType, actorId);
const initials = getActorInitials(actorType, actorId);
const isAgent = actorType === "agent";
return (
<div
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium ${
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={name}
>
{isAgent ? (
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
) : (
initials
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Property row
// ---------------------------------------------------------------------------
@ -138,7 +104,7 @@ function DueDatePicker({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
{date ? (
<span className={isOverdue ? "text-red-500" : ""}>
<span className={isOverdue ? "text-destructive" : ""}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
) : (
@ -156,15 +122,17 @@ function DueDatePicker({
/>
{date && (
<div className="border-t px-3 py-2">
<button
<Button
variant="ghost"
size="xs"
onClick={() => {
onUpdate({ due_date: null });
setOpen(false);
}}
className="text-xs text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground"
>
Clear date
</button>
</Button>
</div>
)}
</PopoverContent>
@ -207,12 +175,14 @@ function AcceptanceCriteriaEditor({
<div key={i} className="group flex items-start gap-2 text-sm">
<span className="mt-0.5 text-muted-foreground">&bull;</span>
<span className="flex-1">{item}</span>
<button
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeItem(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
<X className="h-3.5 w-3.5" />
</button>
</Button>
</div>
))}
</div>
@ -268,18 +238,20 @@ function ContextRefsEditor({
<div key={i} className="group flex items-center gap-2 text-sm">
<Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{isUrl(ref) ? (
<a href={ref} target="_blank" rel="noopener noreferrer" className="flex-1 text-blue-500 hover:underline truncate">
<a href={ref} target="_blank" rel="noopener noreferrer" className="flex-1 text-info hover:underline truncate">
{ref}
</a>
) : (
<span className="flex-1 truncate">{ref}</span>
)}
<button
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeRef(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
<X className="h-3.5 w-3.5" />
</button>
</Button>
</div>
))}
</div>
@ -358,41 +330,44 @@ function RepositoryEditor({
<PopoverContent align="end" className="w-auto min-w-48 p-3 space-y-2.5">
<div className="text-xs font-medium">Repository</div>
<div className="space-y-2">
<input
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://github.com/org/repo"
className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
className="text-xs"
autoFocus
/>
<input
<Input
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="Branch"
className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
className="text-xs"
/>
<input
<Input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="Path"
className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
className="text-xs"
/>
</div>
<div className="flex items-center justify-between pt-1">
{repository && (
<button
<Button
variant="ghost"
size="xs"
onClick={clear}
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
className="text-muted-foreground hover:text-destructive"
>
Remove
</button>
</Button>
)}
<button
<Button
size="xs"
onClick={save}
className="ml-auto rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:bg-primary/90"
className="ml-auto"
>
Save
</button>
</Button>
</div>
</PopoverContent>
</Popover>
@ -410,7 +385,7 @@ export default function IssueDetailPage({
}) {
const { id } = use(params);
const router = useRouter();
const { user, getActorName } = useAuth();
const { user, getActorName, getActorInitials } = useAuth();
const { updateTabTitle, activeTabId, closeTabByPath } = useTabStore();
const [issue, setIssue] = useState<Issue | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
@ -576,7 +551,7 @@ export default function IssueDetailPage({
</div>
<AlertDialog>
<AlertDialogTrigger
render={<button className="rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors" />}
render={<Button variant="ghost" size="icon-xs" className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive" />}
>
<Trash2 className="h-4 w-4" />
</AlertDialogTrigger>
@ -644,6 +619,8 @@ export default function IssueDetailPage({
actorType={comment.author_type}
actorId={comment.author_id}
size={28}
getName={getActorName}
getInitials={getActorInitials}
/>
<span className="text-[13px] font-medium">
{getActorName(comment.author_type, comment.author_id)}
@ -653,18 +630,22 @@ export default function IssueDetailPage({
</span>
{isOwn && (
<div className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
<Button
variant="ghost"
size="icon-xs"
onClick={() => startEditComment(comment)}
className="p-1 text-muted-foreground hover:text-foreground rounded"
className="text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</button>
<button
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDeleteComment(comment.id)}
className="p-1 text-muted-foreground hover:text-destructive rounded"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</Button>
</div>
)}
</div>
@ -691,20 +672,20 @@ export default function IssueDetailPage({
{/* Comment input */}
<form onSubmit={handleSubmitComment} className="mt-2 border-t pt-4">
<div className="flex items-center gap-2">
<input
<Input
type="text"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Leave a comment..."
className="flex-1 rounded-md border bg-background px-3 py-2 text-[13px] placeholder:text-muted-foreground"
className="flex-1 text-[13px]"
/>
<button
<Button
type="submit"
size="icon"
disabled={!commentText.trim() || submitting}
className="rounded-md bg-primary p-2 text-primary-foreground disabled:opacity-50"
>
<Send className="h-3.5 w-3.5" />
</button>
</Button>
</div>
</form>
</div>
@ -748,6 +729,8 @@ export default function IssueDetailPage({
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
getName={getActorName}
getInitials={getActorInitials}
/>
<span>{getActorName(issue.creator_type, issue.creator_id)}</span>
</PropRow>

View file

@ -12,9 +12,9 @@ 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" },
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
high: { label: "High", bars: 3, color: "text-warning" },
medium: { label: "Medium", bars: 2, color: "text-warning" },
low: { label: "Low", bars: 1, color: "text-info" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
};

View file

@ -24,9 +24,9 @@ export const STATUS_CONFIG: Record<
> = {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-yellow-500", hoverBg: "hover:bg-yellow-500/10" },
in_review: { label: "In Review", iconColor: "text-green-500", hoverBg: "hover:bg-green-500/10" },
done: { label: "Done", iconColor: "text-blue-500", hoverBg: "hover:bg-blue-500/10" },
blocked: { label: "Blocked", iconColor: "text-red-500", hoverBg: "hover:bg-red-500/10" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
};

View file

@ -8,7 +8,6 @@ import {
Columns3,
List,
Plus,
Bot,
} from "lucide-react";
import {
DndContext,
@ -33,42 +32,23 @@ import {
DialogFooter,
DialogTrigger,
} from "@multica/ui/components/ui/dialog";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Textarea } from "@multica/ui/components/ui/textarea";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import { StatusIcon, PriorityIcon } from "./_components";
import { api } from "../../../lib/api";
import { useAuth } from "../../../lib/auth-context";
import { useWSEvent } from "../../../lib/ws-context";
import type { IssueCreatedPayload, IssueUpdatedPayload, IssueDeletedPayload } from "@multica/types";
function AssigneeAvatar({
issue,
size = "sm",
}: {
issue: Issue;
size?: "sm" | "md";
}) {
const { getActorName, getActorInitials } = useAuth();
if (!issue.assignee_type || !issue.assignee_id) return null;
const name = getActorName(issue.assignee_type, issue.assignee_id);
const initials = getActorInitials(issue.assignee_type, issue.assignee_id);
const sizeClass = size === "sm" ? "h-5 w-5 text-[10px]" : "h-6 w-6 text-xs";
return (
<div
className={`flex shrink-0 items-center justify-center rounded-full font-medium ${sizeClass} ${
issue.assignee_type === "agent"
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
: "bg-muted text-muted-foreground"
}`}
title={name}
>
{issue.assignee_type === "agent" ? (
<Bot className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
) : (
initials
)}
</div>
);
}
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
@ -81,6 +61,7 @@ function formatDate(date: string): string {
// ---------------------------------------------------------------------------
function BoardCardContent({ issue }: { issue: Issue }) {
const { getActorName, getActorInitials } = useAuth();
return (
<div className="rounded-lg border bg-background p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
@ -90,7 +71,15 @@ function BoardCardContent({ issue }: { issue: Issue }) {
<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 issue={issue} />
{issue.assignee_type && issue.assignee_id && (
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={20}
getName={getActorName}
getInitials={getActorInitials}
/>
)}
</div>
{issue.due_date && (
<span className="text-xs text-muted-foreground">
@ -276,6 +265,7 @@ function BoardView({
// ---------------------------------------------------------------------------
function ListRow({ issue }: { issue: Issue }) {
const { getActorName, getActorInitials } = useAuth();
return (
<TabLink
href={`/issues/${issue.id}`}
@ -294,7 +284,15 @@ function ListRow({ issue }: { issue: Issue }) {
{formatDate(issue.due_date)}
</span>
)}
<AssigneeAvatar issue={issue} />
{issue.assignee_type && issue.assignee_id && (
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={20}
getName={getActorName}
getInitials={getActorInitials}
/>
)}
</TabLink>
);
}
@ -374,10 +372,10 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogTrigger
render={
<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">
<Button size="sm">
<Plus className="h-3.5 w-3.5" />
New Issue
</button>
</Button>
}
/>
<DialogContent className="sm:max-w-lg">
@ -385,7 +383,7 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
<DialogTitle>New Issue</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<input
<Input
autoFocus
type="text"
value={title}
@ -397,52 +395,48 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
}
}}
placeholder="Issue title"
className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring"
/>
<textarea
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add description..."
rows={3}
className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring resize-none"
className="resize-none"
/>
<div className="flex items-center gap-3 flex-wrap">
{/* Status selector */}
<div className="flex items-center gap-1.5 text-xs">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<select
value={status}
onChange={(e) => setStatus(e.target.value as IssueStatus)}
className="bg-transparent text-xs outline-none cursor-pointer"
>
<Select value={status} onValueChange={(v) => setStatus(v as IssueStatus)}>
<SelectTrigger size="sm" className="text-xs">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALL_STATUSES.map((s) => (
<option key={s} value={s}>{STATUS_CONFIG[s].label}</option>
<SelectItem key={s} value={s}>{STATUS_CONFIG[s].label}</SelectItem>
))}
</select>
</div>
</SelectContent>
</Select>
{/* Priority selector */}
<div className="flex items-center gap-1.5 text-xs">
<PriorityIcon priority={priority} />
<select
value={priority}
onChange={(e) => setPriority(e.target.value as IssuePriority)}
className="bg-transparent text-xs outline-none cursor-pointer"
>
<Select value={priority} onValueChange={(v) => setPriority(v as IssuePriority)}>
<SelectTrigger size="sm" className="text-xs">
<PriorityIcon priority={priority} />
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITY_ORDER.map((p) => (
<option key={p} value={p}>{PRIORITY_CONFIG[p].label}</option>
<SelectItem key={p} value={p}>{PRIORITY_CONFIG[p].label}</SelectItem>
))}
</select>
</div>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<button
<Button
onClick={handleSubmit}
disabled={!title.trim() || submitting}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? "Creating..." : "Create Issue"}
</button>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -548,50 +542,56 @@ export default function IssuesPage() {
<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
<Button
variant="ghost"
size="xs"
onClick={() => setView("board")}
className={`flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
className={
view === "board"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
}
>
<Columns3 className="h-3 w-3" />
Board
</button>
<button
</Button>
<Button
variant="ghost"
size="xs"
onClick={() => setView("list")}
className={`flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
className={
view === "list"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
}
>
<List className="h-3 w-3" />
List
</button>
</Button>
</div>
<div className="flex items-center gap-2">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as IssueStatus | "")}
className="rounded-md border bg-background px-2 py-1 text-xs outline-none"
>
<option value="">All Status</option>
{ALL_STATUSES.map((s) => (
<option key={s} value={s}>{STATUS_CONFIG[s].label}</option>
))}
</select>
<select
value={filterPriority}
onChange={(e) => setFilterPriority(e.target.value as IssuePriority | "")}
className="rounded-md border bg-background px-2 py-1 text-xs outline-none"
>
<option value="">All Priority</option>
{PRIORITY_ORDER.map((p) => (
<option key={p} value={p}>{PRIORITY_CONFIG[p].label}</option>
))}
</select>
<Select value={filterStatus || undefined} onValueChange={(v) => setFilterStatus((v ?? "") as IssueStatus | "")}>
<SelectTrigger size="sm" className="text-xs">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Status</SelectItem>
{ALL_STATUSES.map((s) => (
<SelectItem key={s} value={s}>{STATUS_CONFIG[s].label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterPriority || undefined} onValueChange={(v) => setFilterPriority((v ?? "") as IssuePriority | "")}>
<SelectTrigger size="sm" className="text-xs">
<SelectValue placeholder="All Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Priority</SelectItem>
{PRIORITY_ORDER.map((p) => (
<SelectItem key={p} value={p}>{PRIORITY_CONFIG[p].label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<CreateIssueDialog onCreated={handleIssueCreated} />

View file

@ -7,6 +7,8 @@ import {
Search,
Link as LinkIcon,
} from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@ -293,21 +295,21 @@ export default function KnowledgeBasePage() {
<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">
<Button variant="ghost" size="icon-xs">
<Plus className="h-4 w-4 text-muted-foreground" />
</button>
</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
<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"
className="border-0 bg-transparent shadow-none focus-visible:ring-0 flex-1 text-[13px]"
/>
</div>
</div>

View file

@ -3,6 +3,17 @@
import { useEffect, useState } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react";
import type { MemberWithUser, MemberRole } from "@multica/types";
import { Input } from "@multica/ui/components/ui/input";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
import { useAuth } from "../../../lib/auth-context";
import { api } from "../../../lib/api";
@ -49,16 +60,14 @@ function MemberRow({
<div className="text-xs text-muted-foreground">{member.email}</div>
</div>
{canEditRole ? (
<select
value={member.role}
onChange={(e) => onRoleChange(e.target.value as MemberRole)}
disabled={busy}
className="rounded-md border bg-background px-2 py-1 text-xs"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
{canManageOwners && <option value="owner">Owner</option>}
</select>
<Select value={member.role} onValueChange={(value) => onRoleChange(value as MemberRole)} disabled={busy}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{canManageOwners && <SelectItem value="owner">Owner</SelectItem>}
</SelectContent>
</Select>
) : (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RoleIcon className="h-3 w-3" />
@ -66,14 +75,15 @@ function MemberRow({
</div>
)}
{canRemove && (
<button
<Button
variant="ghost"
size="icon-sm"
onClick={onRemove}
disabled={busy}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
aria-label={`Remove ${member.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</Button>
)}
</div>
);
@ -256,43 +266,43 @@ export default function SettingsPage() {
<div className="space-y-3 rounded-lg border p-4">
<div>
<label className="text-xs font-medium text-muted-foreground">
<Label className="text-xs text-muted-foreground">
Name
</label>
<input
</Label>
<Input
type="search"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
className="mt-1"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
<Label className="text-xs text-muted-foreground">
Avatar URL
</label>
<input
</Label>
<Input
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
className="mt-1"
/>
</div>
{profileError && (
<p className="text-xs text-red-500">{profileError}</p>
<p className="text-xs text-destructive">{profileError}</p>
)}
<div className="flex items-center justify-end gap-2 pt-1">
{profileSaved && (
<span className="text-xs text-green-600">Saved!</span>
<span className="text-xs text-success">Saved!</span>
)}
<button
<Button
size="sm"
onClick={handleProfileSave}
disabled={profileSaving || !profileName.trim()}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Save className="h-3 w-3" />
{profileSaving ? "Updating..." : "Update Profile"}
</button>
</Button>
</div>
</div>
</section>
@ -306,53 +316,53 @@ export default function SettingsPage() {
<div className="space-y-3 rounded-lg border p-4">
<div>
<label className="text-xs font-medium text-muted-foreground">
<Label className="text-xs text-muted-foreground">
Name
</label>
<input
</Label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!canManageWorkspace}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
className="mt-1"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
<Label className="text-xs text-muted-foreground">
Description
</label>
<textarea
</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
disabled={!canManageWorkspace}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
className="mt-1 resize-none"
placeholder="What does this workspace focus on?"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
<Label className="text-xs text-muted-foreground">
Slug
</label>
</Label>
<div className="mt-1 rounded-md border bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
{workspace.slug}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
{workspaceError && (
<span className="text-xs text-red-500">{workspaceError}</span>
<span className="text-xs text-destructive">{workspaceError}</span>
)}
{saved && (
<span className="text-xs text-green-600">Saved!</span>
<span className="text-xs text-success">Saved!</span>
)}
<button
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !canManageWorkspace}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</button>
</Button>
</div>
{!canManageWorkspace && (
<p className="text-xs text-muted-foreground">
@ -374,7 +384,7 @@ export default function SettingsPage() {
</div>
{memberError && (
<p className="text-sm text-red-500">{memberError}</p>
<p className="text-sm text-destructive">{memberError}</p>
)}
{canManageWorkspace && (
@ -384,29 +394,26 @@ export default function SettingsPage() {
<h3 className="text-sm font-medium">Add member</h3>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
<input
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="user@company.com"
className="rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as MemberRole)}
className="rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
{isOwner && <option value="owner">Owner</option>}
</select>
<button
<Select value={inviteRole} onValueChange={(value) => setInviteRole(value as MemberRole)}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{isOwner && <SelectItem value="owner">Owner</SelectItem>}
</SelectContent>
</Select>
<Button
onClick={handleAddMember}
disabled={inviteLoading || !inviteEmail.trim()}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{inviteLoading ? "Adding..." : "Add"}
</button>
</Button>
</div>
</div>
)}
@ -444,30 +451,32 @@ export default function SettingsPage() {
Remove yourself from this workspace.
</p>
</div>
<button
<Button
variant="outline"
size="sm"
onClick={handleLeaveWorkspace}
disabled={memberActionId === "leave"}
className="rounded-md border px-3 py-2 text-sm hover:bg-accent disabled:opacity-50"
>
{memberActionId === "leave" ? "Leaving..." : "Leave workspace"}
</button>
</Button>
</div>
{isOwner && (
<div className="flex flex-col gap-2 border-t pt-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-red-600">Delete workspace</p>
<p className="text-sm font-medium text-destructive">Delete workspace</p>
<p className="text-xs text-muted-foreground">
Permanently delete this workspace and its data.
</p>
</div>
<button
<Button
variant="destructive"
size="sm"
onClick={handleDeleteWorkspace}
disabled={memberActionId === "delete-workspace"}
className="rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{memberActionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
</button>
</Button>
</div>
)}
</div>

View file

@ -4,6 +4,15 @@ import Link from "next/link";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { DaemonPairingSession } from "@multica/types";
import { Button } from "@multica/ui/components/ui/button";
import { Label } from "@multica/ui/components/ui/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
import { api } from "../../../lib/api";
import { useAuth } from "../../../lib/auth-context";
@ -77,7 +86,7 @@ function LocalDaemonPairPageContent() {
{loading || isLoading ? (
<div className="mt-8 text-sm text-muted-foreground">Loading pairing session...</div>
) : error ? (
<div className="mt-8 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<div className="mt-8 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{error}
</div>
) : session ? (
@ -109,11 +118,11 @@ function LocalDaemonPairPageContent() {
</Link>
</div>
) : session.status === "approved" || session.status === "claimed" ? (
<div className="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
<div className="mt-6 rounded-xl border border-success/30 bg-success/5 px-4 py-3 text-sm text-success">
This runtime is linked to a workspace. Return to the daemon window to finish setup.
</div>
) : session.status === "expired" ? (
<div className="mt-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<div className="mt-6 rounded-xl border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
This pairing link expired. Restart the daemon to generate a new link.
</div>
) : workspaces.length === 0 ? (
@ -123,28 +132,28 @@ function LocalDaemonPairPageContent() {
) : (
<div className="mt-6 space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">Workspace</label>
<select
value={selectedWorkspaceId}
onChange={(e) => setSelectedWorkspaceId(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
{workspaces.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<Label className="mb-2">Workspace</Label>
<Select value={selectedWorkspaceId} onValueChange={(v) => setSelectedWorkspaceId(v ?? "")}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{workspaces.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<button
<Button
type="button"
onClick={approve}
disabled={submitting || !selectedWorkspaceId}
className="inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? "Registering..." : "Register runtime"}
</button>
</Button>
</div>
)}
</>

View file

@ -9,6 +9,7 @@
"./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx",
"./components/ui/*": "./src/components/ui/*.tsx",
"./components/common/*": "./src/components/common/*.tsx",
"./components/markdown": "./src/components/markdown/index.ts",
"./hooks/*": "./src/hooks/*.ts"
},

View file

@ -0,0 +1,44 @@
import { Bot } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
interface ActorAvatarProps {
actorType: string;
actorId: string;
size?: number;
getName?: (type: string, id: string) => string;
getInitials?: (type: string, id: string) => string;
className?: string;
}
function ActorAvatar({
actorType,
actorId,
size = 20,
getName,
getInitials,
className,
}: ActorAvatarProps) {
const name = getName?.(actorType, actorId);
const initials = getInitials?.(actorType, actorId);
const isAgent = actorType === "agent";
return (
<div
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-medium",
isAgent ? "bg-info/10 text-info" : "bg-muted text-muted-foreground",
className
)}
style={{ width: size, height: size, fontSize: size * 0.45 }}
title={name}
>
{isAgent ? (
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
) : (
initials
)}
</div>
);
}
export { ActorAvatar, type ActorAvatarProps };

View file

@ -104,6 +104,9 @@
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
@ -175,6 +178,11 @@
--tool-running: oklch(0.6 0.18 250); /* Blue: active/in-progress */
--tool-success: oklch(0.72 0.12 145); /* Green: completed */
--tool-error: oklch(0.65 0.2 25); /* Red: failed */
/* Semantic status colors — for general UI (badges, alerts, indicators) */
--success: oklch(0.55 0.16 145); /* Green: saved, completed, online */
--warning: oklch(0.75 0.16 85); /* Yellow/amber: blocked, attention */
--info: oklch(0.55 0.18 250); /* Blue: links, dispatched, done */
}
/* =========================================================================
@ -221,6 +229,10 @@
--tool-running: oklch(0.65 0.2 250);
--tool-success: oklch(0.65 0.15 145);
--tool-error: oklch(0.7 0.2 22);
--success: oklch(0.65 0.15 145);
--warning: oklch(0.70 0.16 85);
--info: oklch(0.65 0.18 250);
}
/* Shiki dual themes: CSS-only light/dark switching via CSS variables */