From 8f4680c0e91209ff27e11ff216bee75f712f6f31 Mon Sep 17 00:00:00 2001
From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Date: Tue, 24 Mar 2026 14:13:55 +0800
Subject: [PATCH] refactor(web): unify design system with shadcn components and
semantic tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add success/warning/info semantic design tokens to globals.css
- Replace all raw HTML elements (input, select, textarea, button, label)
with shadcn components (Input, Select, Textarea, Button, Label, Dialog)
across settings, issues, agents, inbox, knowledge-base, and pair pages
- Replace all hardcoded Tailwind colors with design tokens
(text-red-500 → text-destructive, text-green-600 → text-success, etc.)
- Extract shared ActorAvatar component to packages/ui/components/common
- Update status and priority configs to use semantic tokens
- Update CLAUDE.md with component organization guidelines
- Fix login page tests to use label-based queries
Co-Authored-By: Claude Opus 4.6 (1M context)
---
CLAUDE.md | 3 +
apps/web/app/(auth)/login/page.test.tsx | 16 +-
.../(dashboard)/_components/app-sidebar.tsx | 118 +++----
apps/web/app/(dashboard)/agents/page.tsx | 319 +++++++++---------
apps/web/app/(dashboard)/inbox/page.tsx | 13 +-
apps/web/app/(dashboard)/issues/[id]/page.tsx | 125 +++----
.../(dashboard)/issues/_config/priority.ts | 8 +-
.../app/(dashboard)/issues/_config/status.ts | 8 +-
apps/web/app/(dashboard)/issues/page.tsx | 180 +++++-----
.../app/(dashboard)/knowledge-base/page.tsx | 10 +-
apps/web/app/(dashboard)/settings/page.tsx | 135 ++++----
apps/web/app/pair/local/page.tsx | 45 ++-
packages/ui/package.json | 1 +
.../ui/src/components/common/actor-avatar.tsx | 44 +++
packages/ui/src/styles/globals.css | 12 +
15 files changed, 557 insertions(+), 480 deletions(-)
create mode 100644 packages/ui/src/components/common/actor-avatar.tsx
diff --git a/CLAUDE.md b/CLAUDE.md
index 57c1342a..13c0adb9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx
index d9d97164..84f484ef 100644
--- a/apps/web/app/(auth)/login/page.test.tsx
+++ b/apps/web/app/(auth)/login/page.test.tsx
@@ -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();
- 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();
- 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();
- 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();
- 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(() => {
diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx
index 406f4713..b8999e26 100644
--- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx
+++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx
@@ -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"
>
Sign out
@@ -230,66 +241,57 @@ export function AppSidebar() {
{/* Create Workspace Dialog */}
- {showCreateDialog && (
- <>
- setShowCreateDialog(false)}
- />
-
-
-
- Create workspace
-
-
- Create a new workspace for your team.
-
+
-
);
@@ -389,74 +390,73 @@ function AddToolDialog({
};
return (
- <>
-
-
-
Add Tool
-
- Connect an external tool for this agent to use.
-
+
{ if (!v) onClose(); }}>
+
+
+ Add Tool
+
+ Connect an external tool for this agent to use.
+
+
-
+
-
-
+
+
Cancel
-
-
+
Add
-
-
-
- >
+
+
+
+
);
}
@@ -507,22 +507,23 @@ function ToolsTab({
{isDirty && (
-
{saving ? "Saving..." : "Save"}
-
+
)}
-
setShowAdd(true)}
- className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Add Tool
-
+
@@ -530,13 +531,14 @@ function ToolsTab({
No tools configured
-
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"
>
Add Tool
-
+
) : (
@@ -563,22 +565,26 @@ function ToolsTab({
)}
- 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"}
-
-
+ 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"
>
-
+
))}
@@ -661,14 +667,14 @@ function TriggersTab({
{isDirty && (
-
{saving ? "Saving..." : "Save"}
-
+
)}
@@ -710,22 +716,24 @@ function TriggersTab({
}`}
/>
- 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"
>
-
+
{trigger.type === "scheduled" && (
@@ -762,20 +770,24 @@ function TriggersTab({
- 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"
>
Add On Assign
-
-
+ 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"
>
Add Scheduled
-
+
);
@@ -909,12 +921,13 @@ function AgentDetail({
)}
-
setShowMenu(!showMenu)}
- className="rounded-md p-1.5 hover:bg-accent"
>
-
+
{showMenu && (
<>
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"
>
Delete Agent
@@ -978,15 +991,11 @@ function AgentDetail({
{/* Delete Confirmation */}
{confirmDelete && (
- <>
-
setConfirmDelete(false)}
- />
-
+
{ if (!v) setConfirmDelete(false); }}>
+
-
-
+
Delete agent?
@@ -995,25 +1004,22 @@ function AgentDetail({
-
- setConfirmDelete(false)}
- className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
- >
+
+ setConfirmDelete(false)}>
Cancel
-
-
+ {
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
-
-
-
- >
+
+
+
+
)}
);
@@ -1090,24 +1096,26 @@ export default function AgentsPage() {
Agents
-
setShowCreate(true)}
- className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-accent"
>
-
+
{agents.length === 0 ? (
No agents yet
-
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"
>
Create Agent
-
+
) : (
@@ -1136,13 +1144,14 @@ export default function AgentsPage() {
Select an agent to view details
-
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"
>
Create Agent
-
+
)}
diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx
index 8b729d7d..ca3641dd 100644
--- a/apps/web/app/(dashboard)/inbox/page.tsx
+++ b/apps/web/app/(dashboard)/inbox/page.tsx
@@ -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
= {
};
const severityColors: Record = {
- 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({
{!item.read && (
-
onMarkRead(item.id)}
- className="shrink-0 rounded-md border px-2 py-1 text-xs hover:bg-accent"
+ className="shrink-0"
>
Mark read
-
+
)}
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx
index c6789ed1..ccc9bae4 100644
--- a/apps/web/app/(dashboard)/issues/[id]/page.tsx
+++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx
@@ -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 (
-
- {isAgent ? (
-
- ) : (
- initials
- )}
-
- );
-}
-
// ---------------------------------------------------------------------------
// Property row
// ---------------------------------------------------------------------------
@@ -138,7 +104,7 @@ function DueDatePicker({
{date ? (
-
+
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
) : (
@@ -156,15 +122,17 @@ function DueDatePicker({
/>
{date && (
- {
onUpdate({ due_date: null });
setOpen(false);
}}
- className="text-xs text-muted-foreground hover:text-foreground"
+ className="text-muted-foreground hover:text-foreground"
>
Clear date
-
+
)}
@@ -207,12 +175,14 @@ function AcceptanceCriteriaEditor({
•
{item}
- removeItem(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
-
+
))}
@@ -268,18 +238,20 @@ function ContextRefsEditor({
{isUrl(ref) ? (
-
+
{ref}
) : (
{ref}
)}
-
removeRef(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
-
+
))}
@@ -358,41 +330,44 @@ function RepositoryEditor({
Repository
- 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
/>
- 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"
/>
- 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"
/>
{repository && (
-
Remove
-
+
)}
-
Save
-
+
@@ -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(null);
const [comments, setComments] = useState([]);
@@ -576,7 +551,7 @@ export default function IssueDetailPage({
}
+ render={}
>
@@ -644,6 +619,8 @@ export default function IssueDetailPage({
actorType={comment.author_type}
actorId={comment.author_id}
size={28}
+ getName={getActorName}
+ getInitials={getActorInitials}
/>
{getActorName(comment.author_type, comment.author_id)}
@@ -653,18 +630,22 @@ export default function IssueDetailPage({
{isOwn && (
-
startEditComment(comment)}
- className="p-1 text-muted-foreground hover:text-foreground rounded"
+ className="text-muted-foreground hover:text-foreground"
>
-
-
+ handleDeleteComment(comment.id)}
- className="p-1 text-muted-foreground hover:text-destructive rounded"
+ className="text-muted-foreground hover:text-destructive"
>
-
+
)}
@@ -691,20 +672,20 @@ export default function IssueDetailPage({
{/* Comment input */}
@@ -748,6 +729,8 @@ export default function IssueDetailPage({
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
+ getName={getActorName}
+ getInitials={getActorInitials}
/>
{getActorName(issue.creator_type, issue.creator_id)}
diff --git a/apps/web/app/(dashboard)/issues/_config/priority.ts b/apps/web/app/(dashboard)/issues/_config/priority.ts
index 59345ecd..202a61e3 100644
--- a/apps/web/app/(dashboard)/issues/_config/priority.ts
+++ b/apps/web/app/(dashboard)/issues/_config/priority.ts
@@ -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" },
};
diff --git a/apps/web/app/(dashboard)/issues/_config/status.ts b/apps/web/app/(dashboard)/issues/_config/status.ts
index f00a7964..9e0fa809 100644
--- a/apps/web/app/(dashboard)/issues/_config/status.ts
+++ b/apps/web/app/(dashboard)/issues/_config/status.ts
@@ -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" },
};
diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx
index 4e3e5cae..7d3fab07 100644
--- a/apps/web/app/(dashboard)/issues/page.tsx
+++ b/apps/web/app/(dashboard)/issues/page.tsx
@@ -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 (
-
- {issue.assignee_type === "agent" ? (
-
- ) : (
- initials
- )}
-
- );
-}
-
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 (
@@ -90,7 +71,15 @@ function BoardCardContent({ issue }: { issue: Issue }) {
{issue.title}
-
+ {issue.assignee_type && issue.assignee_id && (
+
+ )}
{issue.due_date && (
@@ -276,6 +265,7 @@ function BoardView({
// ---------------------------------------------------------------------------
function ListRow({ issue }: { issue: Issue }) {
+ const { getActorName, getActorInitials } = useAuth();
return (
)}
-
+ {issue.assignee_type && issue.assignee_id && (
+
+ )}
);
}
@@ -374,10 +372,10 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
{ setOpen(v); if (!v) reset(); }}>
+
New Issue
-
+
}
/>
@@ -385,7 +383,7 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
New Issue
-
{submitting ? "Creating..." : "Create Issue"}
-
+
@@ -548,50 +542,56 @@ export default function IssuesPage() {
All Issues
- 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"
- }`}
+ }
>
Board
-
-
+ 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
-
+
-
-
+
+
diff --git a/apps/web/app/(dashboard)/knowledge-base/page.tsx b/apps/web/app/(dashboard)/knowledge-base/page.tsx
index 925d3238..a49168b4 100644
--- a/apps/web/app/(dashboard)/knowledge-base/page.tsx
+++ b/apps/web/app/(dashboard)/knowledge-base/page.tsx
@@ -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() {
{/* Search */}
diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx
index af2a7ea1..dba1b245 100644
--- a/apps/web/app/(dashboard)/settings/page.tsx
+++ b/apps/web/app/(dashboard)/settings/page.tsx
@@ -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({
{member.email}
{canEditRole ? (
-
+
) : (
@@ -66,14 +75,15 @@ function MemberRow({
)}
{canRemove && (
-
-
+
)}
);
@@ -256,43 +266,43 @@ export default function SettingsPage() {
@@ -306,53 +316,53 @@ export default function SettingsPage() {
-
-
+
Description
-
-
-
+
Slug
-
+
{workspace.slug}
{workspaceError && (
- {workspaceError}
+ {workspaceError}
)}
{saved && (
- Saved!
+ Saved!
)}
-
{saving ? "Saving..." : "Save"}
-
+
{!canManageWorkspace && (
@@ -374,7 +384,7 @@ export default function SettingsPage() {
{memberError && (
-
{memberError}
+
{memberError}
)}
{canManageWorkspace && (
@@ -384,29 +394,26 @@ export default function SettingsPage() {
Add member
- 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"
/>
-
- setInviteRole(value as MemberRole)}>
+
+
+ Member
+ Admin
+ {isOwner && Owner}
+
+
+
{inviteLoading ? "Adding..." : "Add"}
-
+
)}
@@ -444,30 +451,32 @@ export default function SettingsPage() {
Remove yourself from this workspace.
-
{memberActionId === "leave" ? "Leaving..." : "Leave workspace"}
-
+
{isOwner && (
-
Delete workspace
+
Delete workspace
Permanently delete this workspace and its data.
-
{memberActionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
-
+
)}
diff --git a/apps/web/app/pair/local/page.tsx b/apps/web/app/pair/local/page.tsx
index 023496d9..8dc70471 100644
--- a/apps/web/app/pair/local/page.tsx
+++ b/apps/web/app/pair/local/page.tsx
@@ -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 ? (
Loading pairing session...
) : error ? (
-
+
{error}
) : session ? (
@@ -109,11 +118,11 @@ function LocalDaemonPairPageContent() {
) : session.status === "approved" || session.status === "claimed" ? (
-
+
This runtime is linked to a workspace. Return to the daemon window to finish setup.
) : session.status === "expired" ? (
-
+
This pairing link expired. Restart the daemon to generate a new link.
) : workspaces.length === 0 ? (
@@ -123,28 +132,28 @@ function LocalDaemonPairPageContent() {
) : (
- Workspace
-
+ Workspace
+
-
{submitting ? "Registering..." : "Register runtime"}
-
+
)}
>
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 17fd3459..4f329977 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -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"
},
diff --git a/packages/ui/src/components/common/actor-avatar.tsx b/packages/ui/src/components/common/actor-avatar.tsx
new file mode 100644
index 00000000..a5ced65f
--- /dev/null
+++ b/packages/ui/src/components/common/actor-avatar.tsx
@@ -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 (
+
+ {isAgent ? (
+
+ ) : (
+ initials
+ )}
+
+ );
+}
+
+export { ActorAvatar, type ActorAvatarProps };
diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css
index b44eca0c..5c64488a 100644
--- a/packages/ui/src/styles/globals.css
+++ b/packages/ui/src/styles/globals.css
@@ -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 */