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:
commit
4bab6f71fc
16 changed files with 613 additions and 512 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">•</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>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
44
packages/ui/src/components/common/actor-avatar.tsx
Normal file
44
packages/ui/src/components/common/actor-avatar.tsx
Normal 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 };
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue