merge: resolve conflicts after merging main

Adapt runtime features (usage tracking, ping, heartbeat) to main's
multi-workspace architecture. Update frontend imports from @multica/types
to @/shared/types after the package consolidation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan 2026-03-26 18:37:56 +08:00
commit 6ee034c6e9
151 changed files with 3664 additions and 6579 deletions

View file

@ -17,7 +17,6 @@ vi.mock("@/features/auth", () => ({
selector({
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
isLoading: false,
}),
}));
@ -34,6 +33,9 @@ vi.mock("@/features/workspace", () => ({
vi.mock("@/shared/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
verifyCode: vi.fn(),
setToken: vi.fn(),
getMe: vi.fn(),
},
}));
@ -44,14 +46,14 @@ describe("LoginPage", () => {
vi.clearAllMocks();
});
it("renders email form with heading and button", () => {
it("renders login form with email input and continue button", () => {
render(<LoginPage />);
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /continue/i })
screen.getByRole("button", { name: "Continue" })
).toBeInTheDocument();
});
@ -59,25 +61,21 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(mockSendCode).not.toHaveBeenCalled();
});
it("calls sendCode on submit and shows code step", async () => {
it("calls sendCode with email on submit", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(mockSendCode).toHaveBeenCalledWith("test@multica.ai");
});
await waitFor(() => {
expect(screen.getByText("Check your email")).toBeInTheDocument();
});
});
it("shows 'Sending code...' while submitting", async () => {
@ -86,40 +84,36 @@ describe("LoginPage", () => {
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(screen.getByText("Sending code...")).toBeInTheDocument();
});
});
it("shows verification code step after sending code", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(screen.getByText("Check your email")).toBeInTheDocument();
});
});
it("shows error when sendCode fails", async () => {
mockSendCode.mockRejectedValueOnce(new Error("Network error"));
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});
it("shows back button on code step", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(screen.getByText("Check your email")).toBeInTheDocument();
});
expect(
screen.getByRole("button", { name: /back/i })
).toBeInTheDocument();
});
});

View file

@ -21,6 +21,28 @@ import {
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import type { User } from "@/shared/types";
function validateCliCallback(cliCallback: string): boolean {
try {
const cbUrl = new URL(cliCallback);
if (cbUrl.protocol !== "http:") return false;
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
return false;
return true;
} catch {
return false;
}
}
function redirectToCliCallback(
cliCallback: string,
token: string,
cliState: string
) {
const separator = cliCallback.includes("?") ? "&" : "?";
window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`;
}
function LoginPageContent() {
const router = useRouter();
@ -29,12 +51,38 @@ function LoginPageContent() {
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const searchParams = useSearchParams();
const [step, setStep] = useState<"email" | "code">("email");
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [existingUser, setExistingUser] = useState<User | null>(null);
// Check for existing session when CLI callback is present.
useEffect(() => {
const cliCallback = searchParams.get("cli_callback");
if (!cliCallback) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
if (!validateCliCallback(cliCallback)) return;
// Verify the existing token is still valid.
api.setToken(token);
api
.getMe()
.then((user) => {
setExistingUser(user);
setStep("cli_confirm");
})
.catch(() => {
// Token expired/invalid — clear and fall through to normal login.
api.setToken(null);
localStorage.removeItem("multica_token");
});
}, [searchParams]);
useEffect(() => {
if (cooldown <= 0) return;
@ -42,6 +90,15 @@ function LoginPageContent() {
return () => clearTimeout(timer);
}, [cooldown]);
const handleCliAuthorize = async () => {
const cliCallback = searchParams.get("cli_callback");
const token = localStorage.getItem("multica_token");
if (!cliCallback || !token) return;
const cliState = searchParams.get("cli_state") || "";
setSubmitting(true);
redirectToCliCallback(cliCallback, token, cliState);
};
const handleSendCode = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!email) {
@ -57,7 +114,9 @@ function LoginPageContent() {
setCooldown(10);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to send code. Make sure the server is running."
err instanceof Error
? err.message
: "Failed to send code. Make sure the server is running."
);
} finally {
setSubmitting(false);
@ -72,29 +131,14 @@ function LoginPageContent() {
try {
const cliCallback = searchParams.get("cli_callback");
if (cliCallback) {
// CLI browser login: verify code, get JWT, redirect to CLI callback.
// Only allow http://localhost callbacks to prevent open redirect / JWT theft.
try {
const cbUrl = new URL(cliCallback);
if (cbUrl.protocol !== "http:") {
setError("Invalid callback URL");
setSubmitting(false);
return;
}
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1") {
setError("Invalid callback URL");
setSubmitting(false);
return;
}
} catch {
if (!validateCliCallback(cliCallback)) {
setError("Invalid callback URL");
setSubmitting(false);
return;
}
const { token } = await api.verifyCode(email, value);
const cliState = searchParams.get("cli_state") || "";
const separator = cliCallback.includes("?") ? "&" : "?";
window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`;
redirectToCliCallback(cliCallback, token, cliState);
return;
}
@ -126,6 +170,46 @@ function LoginPageContent() {
}
};
// CLI confirm step: user is already logged in, just authorize.
if (step === "cli_confirm" && existingUser) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Authorize CLI</CardTitle>
<CardDescription>
Allow the CLI to access Multica as{" "}
<span className="font-medium text-foreground">
{existingUser.email}
</span>
?
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Button
onClick={handleCliAuthorize}
disabled={submitting}
className="w-full"
size="lg"
>
{submitting ? "Authorizing..." : "Authorize"}
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => {
setExistingUser(null);
setStep("email");
}}
>
Use a different account
</Button>
</CardContent>
</Card>
</div>
);
}
if (step === "code") {
return (
<div className="flex min-h-screen items-center justify-center">

View file

@ -14,7 +14,6 @@ import {
Plus,
Check,
Sparkles,
Search,
SquarePen,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
@ -27,6 +26,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
import {
DropdownMenu,
@ -157,25 +157,15 @@ export function AppSidebar() {
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<Search className="size-4" />
</TooltipTrigger>
<TooltipContent side="bottom">Search</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
className="flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
onClick={() => useModalStore.getState().open("create-issue")}
>
<SquarePen className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="bottom">New issue</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger
className="flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
onClick={() => useModalStore.getState().open("create-issue")}
>
<SquarePen className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="bottom">New issue</TooltipContent>
</Tooltip>
</div>
</SidebarHeader>
@ -230,6 +220,7 @@ export function AppSidebar() {
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import {
Bot,
Cloud,
@ -32,7 +33,7 @@ import type {
RuntimeDevice,
CreateAgentRequest,
UpdateAgentRequest,
} from "@multica/types";
} from "@/shared/types";
import {
Dialog,
DialogContent,
@ -41,6 +42,11 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -1134,6 +1140,9 @@ export default function AgentsPage() {
const [selectedId, setSelectedId] = useState<string>("");
const [showCreate, setShowCreate] = useState(false);
const [runtimes, setRuntimes] = useState<RuntimeDevice[]>([]);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_agents_layout",
});
useEffect(() => {
if (!workspace) {
@ -1191,70 +1200,81 @@ export default function AgentsPage() {
}
return (
<div className="flex flex-1 min-h-0">
{/* Left column — agent list */}
<div className="w-72 shrink-0 overflow-y-auto border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Agents</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
<ResizablePanelGroup
orientation="horizontal"
className="flex-1 min-h-0"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
{/* Left column — agent list */}
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Agents</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</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
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
</div>
) : (
<div className="divide-y">
{agents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}
isSelected={agent.id === selectedId}
onClick={() => setSelectedId(agent.id)}
/>
))}
</div>
)}
</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
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
</div>
) : (
<div className="divide-y">
{agents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}
isSelected={agent.id === selectedId}
onClick={() => setSelectedId(agent.id)}
/>
))}
</div>
)}
</div>
</ResizablePanel>
{/* Right column — agent detail */}
<div className="flex-1 overflow-hidden">
{selected ? (
<AgentDetail
agent={selected}
runtimes={runtimes}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<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
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
</div>
)}
</div>
<ResizableHandle />
<ResizablePanel id="detail" minSize="50%">
{/* Right column — agent detail */}
<div className="flex-1 overflow-hidden h-full">
{selected ? (
<AgentDetail
agent={selected}
runtimes={runtimes}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<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
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
</div>
)}
</div>
</ResizablePanel>
{showCreate && (
<CreateAgentDialog
@ -1263,6 +1283,6 @@ export default function AgentsPage() {
onCreate={handleCreate}
/>
)}
</div>
</ResizablePanelGroup>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import { useState, useMemo } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import { useInboxStore } from "@/features/inbox";
import { IssueDetail, StatusIcon } from "@/features/issues/components";
import { ActorAvatar } from "@/components/common/actor-avatar";
@ -13,8 +14,13 @@ import {
BookCheck,
ListChecks,
} from "lucide-react";
import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types";
import type { InboxItem, InboxItemType, InboxSeverity } from "@/shared/types";
import { Button } from "@/components/ui/button";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import {
DropdownMenu,
@ -82,25 +88,25 @@ function InboxListItem({
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
>
{item.title}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{item.issue_status && (
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5" />
)}
<div className="flex min-w-0 items-center gap-1.5">
{!item.read && (
<span className="h-2 w-2 rounded-full bg-primary" />
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
)}
<span
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
>
{item.title}
</span>
</div>
{item.issue_status && (
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5 shrink-0" />
)}
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className="truncate text-xs text-muted-foreground">
<p className={`truncate text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
{typeLabels[item.type] ?? item.type}
</p>
<span className="shrink-0 text-xs text-muted-foreground">
<span className={`shrink-0 text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
{timeAgo(item.created_at)}
</span>
</div>
@ -119,6 +125,10 @@ export default function InboxPage() {
const storeItems = useInboxStore((s) => s.items);
const loading = useInboxStore((s) => s.loading);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_inbox_layout",
});
// Sort: severity first, then newest first
const items = useMemo(() => {
return [...storeItems]
@ -202,40 +212,46 @@ export default function InboxPage() {
if (loading) {
return (
<div className="flex flex-1 min-h-0">
<div className="w-80 shrink-0 border-r">
<div className="flex h-12 items-center border-b px-4">
<Skeleton className="h-5 w-16" />
</div>
<div className="space-y-1 p-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
<div className="overflow-y-auto border-r h-full">
<div className="flex h-12 items-center border-b px-4">
<Skeleton className="h-5 w-16" />
</div>
<div className="space-y-1 p-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
<div className="flex-1 p-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="mt-4 h-4 w-32" />
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="40%">
<div className="p-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="mt-4 h-4 w-32" />
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
return (
<div className="flex flex-1 min-h-0">
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
{/* Left column — inbox list */}
<div className="w-80 shrink-0 overflow-y-auto border-r">
<div className="overflow-y-auto border-r h-full">
<div className="flex h-12 items-center justify-between border-b px-4">
<div className="flex items-center gap-2">
<h1 className="text-sm font-semibold">Inbox</h1>
{unreadCount > 0 && (
<span className="rounded-full bg-primary px-1.5 py-0.5 text-xs font-medium text-primary-foreground">
<span className="text-xs text-muted-foreground">
{unreadCount}
</span>
)}
@ -280,7 +296,7 @@ export default function InboxPage() {
<p className="text-sm">No notifications</p>
</div>
) : (
<div className="divide-y">
<div>
{items.map((item) => (
<InboxListItem
key={item.id}
@ -292,13 +308,14 @@ export default function InboxPage() {
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="40%">
{/* Right column — detail */}
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col min-h-0 h-full">
{selected?.issue_id ? (
<IssueDetail
issueId={selected.issue_id}
showBreadcrumb={false}
onDelete={() => {
handleArchive(selected.id);
}}
@ -336,6 +353,7 @@ export default function InboxPage() {
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View file

@ -1,8 +1,8 @@
import { Suspense } from "react";
import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Issue, Comment } from "@multica/types";
import type { Issue, Comment } from "@/shared/types";
// Mock next/navigation
vi.mock("next/navigation", () => ({
@ -71,6 +71,41 @@ vi.mock("@/components/ui/calendar", () => ({
Calendar: () => null,
}));
// Mock RichTextEditor (Tiptap needs real DOM)
vi.mock("@/components/common/rich-text-editor", () => ({
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
clearContent: () => { valueRef.current = ""; setValue(""); },
focus: () => {},
}));
return (
<textarea
value={value}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
onSubmit?.();
}
}}
placeholder={placeholder}
data-testid="rich-text-editor"
/>
);
}),
}));
// Mock Markdown renderer
vi.mock("@/components/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
// Mock api
const mockGetIssue = vi.hoisted(() => vi.fn());
const mockListComments = vi.hoisted(() => vi.fn());
@ -234,17 +269,21 @@ describe("IssueDetailPage", () => {
).toBeInTheDocument();
});
await user.type(
screen.getByPlaceholderText("Leave a comment..."),
"New test comment",
);
const commentInput = screen.getByPlaceholderText("Leave a comment...");
const form = screen
.getByPlaceholderText("Leave a comment...")
.closest("form")!;
const submitBtn = form.querySelector(
'button[type="submit"]',
) as HTMLElement;
// Use fireEvent to update the textarea value and trigger onUpdate
await act(async () => {
fireEvent.change(commentInput, { target: { value: "New test comment" } });
});
// Wait for button to be enabled after commentEmpty state update
const allButtons = screen.getAllByRole("button");
const submitBtn = allButtons.find(
(btn) => btn.querySelector(".lucide-arrow-up") !== null,
)!;
await waitFor(() => {
expect(submitBtn).not.toBeDisabled();
});
await user.click(submitBtn);
await waitFor(() => {

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Issue } from "@multica/types";
import type { Issue } from "@/shared/types";
// Mock next/navigation
vi.mock("next/navigation", () => ({

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import {
FileText,
Plus,
@ -9,6 +10,11 @@ import {
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@ -280,6 +286,9 @@ export default function KnowledgeBasePage() {
const [documents] = useState<KBDocument[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
const [search, setSearch] = useState("");
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_kb_layout",
});
const filtered = search
? documents.filter((d) =>
@ -290,10 +299,11 @@ export default function KnowledgeBasePage() {
const selected = documents.find((d) => d.id === selectedId) ?? null;
return (
<div className="flex flex-1 min-h-0">
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
{/* Left: Document list */}
<div className="w-72 shrink-0 overflow-y-auto border-r">
<div className="flex h-11 items-center justify-between border-b px-4">
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Knowledge Base</h1>
<Button variant="ghost" size="icon-xs">
<Plus className="h-4 w-4 text-muted-foreground" />
@ -331,15 +341,20 @@ export default function KnowledgeBasePage() {
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="50%">
{/* Right: Document content */}
{selected ? (
<DocDetail doc={selected} />
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a document
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View file

@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut, Key, Copy, Check } from "lucide-react";
import type { MemberWithUser, MemberRole, PersonalAccessToken } from "@multica/types";
import type { MemberWithUser, MemberRole, PersonalAccessToken } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";

View file

@ -29,6 +29,8 @@
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-canvas: var(--canvas);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
@ -86,6 +88,8 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--brand: oklch(0.55 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--canvas: oklch(0.95 0.002 286);
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
@ -127,6 +131,8 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--brand: oklch(0.65 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--canvas: oklch(0.2 0.005 286);
--success: oklch(0.65 0.15 145);
--warning: oklch(0.70 0.16 85);

View file

@ -3,7 +3,7 @@
import Link from "next/link";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { DaemonPairingSession } from "@multica/types";
import type { DaemonPairingSession } from "@/shared/types";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {

View file

@ -0,0 +1,145 @@
/* Rich text editor: ProseMirror styles using shadcn design tokens */
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
}
.rich-text-editor.ProseMirror:focus {
outline: none;
}
/* Placeholder */
.rich-text-editor .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
pointer-events: none;
height: 0;
}
/* Headings */
.rich-text-editor h1 {
font-size: 1.125rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.rich-text-editor h2 {
font-size: 1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.rich-text-editor h3 {
font-size: 0.875rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
line-height: 1.4;
}
/* Paragraphs */
.rich-text-editor p {
margin-top: 0.375rem;
margin-bottom: 0.375rem;
line-height: 1.625;
}
/* First child should not have top margin */
.rich-text-editor > *:first-child {
margin-top: 0;
}
/* Last child should not have bottom margin */
.rich-text-editor > *:last-child {
margin-bottom: 0;
}
/* Lists */
.rich-text-editor ul {
list-style-type: disc;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
}
.rich-text-editor ol {
list-style-type: decimal;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
}
.rich-text-editor li {
margin: 0.125rem 0;
line-height: 1.625;
}
.rich-text-editor li::marker {
color: var(--muted-foreground);
}
/* Inline code */
.rich-text-editor code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8em;
background: var(--muted);
color: var(--foreground);
padding: 0.15em 0.35em;
border-radius: calc(var(--radius) * 0.6);
}
/* Code blocks */
.rich-text-editor pre {
background: var(--muted);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin: 0.5rem 0;
overflow-x: auto;
}
.rich-text-editor pre code {
background: none;
padding: 0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 2px solid var(--border);
padding-left: 0.75rem;
margin: 0.5rem 0;
color: var(--muted-foreground);
}
/* Horizontal rules */
.rich-text-editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
/* Links */
.rich-text-editor a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Strong / emphasis */
.rich-text-editor strong {
font-weight: 600;
}
.rich-text-editor em {
font-style: italic;
}
.rich-text-editor s {
text-decoration: line-through;
color: var(--muted-foreground);
}

View file

@ -0,0 +1,150 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import { Markdown } from "tiptap-markdown";
import { Extension } from "@tiptap/core";
import { cn } from "@/lib/utils";
import "./rich-text-editor.css";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface RichTextEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
editable?: boolean;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
}
interface RichTextEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
}
// ---------------------------------------------------------------------------
// Submit shortcut extension (Mod+Enter)
// ---------------------------------------------------------------------------
function createSubmitExtension(onSubmit: () => void) {
return Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": () => {
onSubmit();
return true;
},
};
},
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
function RichTextEditor(
{
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
// Helper to get markdown from tiptap-markdown storage
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getEditorMarkdown = (ed: any): string =>
ed?.storage?.markdown?.getMarkdown?.() ?? "";
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Placeholder.configure({
placeholder: placeholderText,
}),
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
}),
Typography,
Markdown.configure({
html: false,
transformPastedText: true,
transformCopiedText: true,
}),
createSubmitExtension(() => onSubmitRef.current?.()),
],
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(getEditorMarkdown(ed));
}, debounceMs);
},
editorProps: {
attributes: {
class: cn("rich-text-editor text-sm outline-none", className),
},
},
});
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
useImperativeHandle(ref, () => ({
getMarkdown: () => getEditorMarkdown(editor),
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
editor?.commands.focus();
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };

View file

@ -35,7 +35,7 @@ function ResizableHandle({
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
"relative flex w-0 items-center justify-center before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors hover:before:bg-foreground/15 data-[separator=active]:before:bg-foreground/15 after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 focus-visible:outline-hidden aria-[orientation=horizontal]:h-0 aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:before:inset-x-0 aria-[orientation=horizontal]:before:inset-y-auto aria-[orientation=horizontal]:before:h-px aria-[orientation=horizontal]:before:w-full aria-[orientation=horizontal]:before:translate-x-0 aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-2 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2",
className
)}
{...props}

View file

@ -27,7 +27,10 @@ import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_DEFAULT = 256
const SIDEBAR_WIDTH_MIN = 200
const SIDEBAR_WIDTH_MAX = 360
const SIDEBAR_WIDTH_STORAGE_KEY = "sidebar_width"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@ -40,6 +43,10 @@ type SidebarContextProps = {
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
width: number
setWidth: (width: number) => void
isResizing: boolean
setIsResizing: (v: boolean) => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
@ -69,6 +76,18 @@ function SidebarProvider({
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const [width, _setWidth] = React.useState(SIDEBAR_WIDTH_DEFAULT)
const [isResizing, setIsResizing] = React.useState(false)
React.useEffect(() => {
const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)
if (stored) _setWidth(Number(stored))
}, [])
const setWidth = React.useCallback((w: number) => {
const clamped = Math.max(SIDEBAR_WIDTH_MIN, Math.min(SIDEBAR_WIDTH_MAX, w))
_setWidth(clamped)
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clamped))
}, [])
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
@ -122,8 +141,12 @@ function SidebarProvider({
openMobile,
setOpenMobile,
toggleSidebar,
width,
setWidth,
isResizing,
setIsResizing,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, width, setWidth, isResizing, setIsResizing]
)
return (
@ -132,7 +155,7 @@ function SidebarProvider({
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width": `${width}px`,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
@ -162,7 +185,7 @@ function Sidebar({
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile, isResizing } = useSidebar()
if (collapsible === "none") {
return (
@ -218,7 +241,8 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"relative w-(--sidebar-width) bg-transparent",
!isResizing && "transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
@ -230,7 +254,8 @@ function Sidebar({
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
!isResizing && "transition-[left,right,width] duration-200 ease-linear",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
@ -278,7 +303,45 @@ function SidebarTrigger({
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar, setWidth, setIsResizing } = useSidebar()
const didDragRef = React.useRef(false)
const dragRef = React.useRef<{ startX: number; startWidth: number } | null>(null)
const onMouseDown = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
didDragRef.current = false
const sidebarEl = (e.target as HTMLElement).closest("[data-slot='sidebar']")
const containerEl = sidebarEl?.querySelector("[data-slot='sidebar-container']")
if (!containerEl) return
dragRef.current = { startX: e.clientX, startWidth: containerEl.getBoundingClientRect().width }
setIsResizing(true)
const onMouseMove = (ev: MouseEvent) => {
if (!dragRef.current) return
didDragRef.current = true
const delta = ev.clientX - dragRef.current.startX
setWidth(dragRef.current.startWidth + delta)
}
const onMouseUp = () => {
dragRef.current = null
setIsResizing(false)
document.removeEventListener("mousemove", onMouseMove)
document.removeEventListener("mouseup", onMouseUp)
document.body.style.cursor = ""
document.body.style.userSelect = ""
}
document.addEventListener("mousemove", onMouseMove)
document.addEventListener("mouseup", onMouseUp)
document.body.style.cursor = "col-resize"
document.body.style.userSelect = "none"
},
[setWidth, setIsResizing]
)
const handleClick = React.useCallback(() => {
if (!didDragRef.current) toggleSidebar()
}, [toggleSidebar])
return (
<button
@ -286,12 +349,12 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
onClick={handleClick}
onMouseDown={onMouseDown}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"in-data-[side=left]:cursor-col-resize in-data-[side=right]:cursor-col-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",

View file

@ -1,7 +1,7 @@
"use client";
import { create } from "zustand";
import type { User } from "@multica/types";
import type { User } from "@/shared/types";
import { api } from "@/shared/api";
interface AuthState {

View file

@ -1,7 +1,7 @@
"use client";
import { create } from "zustand";
import type { InboxItem, IssueStatus } from "@multica/types";
import type { InboxItem, IssueStatus } from "@/shared/types";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";

View file

@ -3,7 +3,8 @@
import Link from "next/link";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Issue } from "@multica/types";
import type { Issue } from "@/shared/types";
import { CalendarDays } from "lucide-react";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { PriorityIcon } from "./priority-icon";
@ -21,7 +22,7 @@ export function BoardCardContent({ issue }: { issue: Issue }) {
<PriorityIcon priority={issue.priority} />
<span>{issue.id.slice(0, 8)}</span>
</div>
<p className="mt-1.5 text-sm leading-snug">{issue.title}</p>
<p className="mt-1.5 text-sm leading-snug line-clamp-2">{issue.title}</p>
<div className="mt-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
{issue.assignee_type && issue.assignee_id && (
@ -33,7 +34,8 @@ export function BoardCardContent({ issue }: { issue: Issue }) {
)}
</div>
{issue.due_date && (
<span className="text-xs text-muted-foreground">
<span className={`flex items-center gap-1 text-xs ${new Date(issue.due_date) < new Date() ? "text-destructive" : "text-muted-foreground"}`}>
<CalendarDays className="size-3" />
{formatDate(issue.due_date)}
</span>
)}

View file

@ -1,8 +1,18 @@
"use client";
import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
import { useDroppable } from "@dnd-kit/core";
import type { Issue, IssueStatus } from "@multica/types";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { STATUS_CONFIG } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card";
@ -17,11 +27,41 @@ export function BoardColumn({
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<div className="flex min-w-52 flex-1 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{cfg.label}</span>
<span className="text-xs text-muted-foreground">{issues.length}</span>
<div className="flex w-64 shrink-0 flex-col">
<div className="mb-2 flex items-center justify-between px-1">
{/* Left: icon + label + count */}
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{cfg.label}</span>
<span className="text-xs text-muted-foreground">{issues.length}</span>
</div>
{/* Right: add + menu */}
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => useIssueViewStore.getState().hideStatus(status)}>
<EyeOff className="size-3.5" />
Hide column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => useModalStore.getState().open("create-issue", { status })}
>
<Plus className="size-3.5" />
</Button>
</div>
</div>
<div
ref={setNodeRef}

View file

@ -13,7 +13,7 @@ import {
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import type { Issue, IssueStatus } from "@multica/types";
import type { Issue, IssueStatus } from "@/shared/types";
import { BoardColumn } from "./board-column";
import { BoardCardContent } from "./board-card";

View file

@ -1,5 +1,5 @@
export { StatusIcon } from "./status-icon";
export { PriorityIcon } from "./priority-icon";
export { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers";
export { StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
export { IssueDetail } from "./issue-detail";
export { IssuesPage } from "./issues-page";

View file

@ -1,14 +1,20 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowUp,
Bot,
Calendar,
ChevronRight,
Link2,
MoreHorizontal,
PanelRight,
Pencil,
Send,
Trash2,
UserMinus,
X,
} from "lucide-react";
import { toast } from "sonner";
@ -21,31 +27,41 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { Markdown } from "@/components/markdown";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
import { StatusPicker, PriorityPicker, AssigneePicker } from "@/features/issues/components";
import type { Issue, Comment, UpdateIssueRequest, IssueStatus, IssuePriority } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useActorName } from "@/features/workspace";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
import { useIssueStore } from "@/features/issues";
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types";
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@/shared/types";
// ---------------------------------------------------------------------------
// Helpers
@ -82,69 +98,15 @@ function PropRow({
children: React.ReactNode;
}) {
return (
<div className="flex min-h-8 items-center gap-3 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors">
<span className="w-20 shrink-0 text-sm text-muted-foreground">{label}</span>
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5 text-sm">
<div className="flex min-h-8 items-center gap-2 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors">
<span className="w-16 shrink-0 text-xs text-muted-foreground">{label}</span>
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-sm truncate">
{children}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Due Date Picker
// ---------------------------------------------------------------------------
function DueDatePicker({
dueDate,
onUpdate,
}: {
dueDate: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const [open, setOpen] = useState(false);
const date = dueDate ? new Date(dueDate) : undefined;
const isOverdue = date ? date < new Date() : false;
return (
<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-destructive" : ""}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
) : (
<span className="text-muted-foreground">None</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={date}
onSelect={(d: Date | undefined) => {
onUpdate({ due_date: d ? d.toISOString() : null });
setOpen(false);
}}
/>
{date && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
onUpdate({ due_date: null });
setOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
Clear date
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// Acceptance Criteria Editor
@ -310,7 +272,6 @@ function ContextRefsEditor({
interface IssueDetailProps {
issueId: string;
showBreadcrumb?: boolean;
onDelete?: () => void;
}
@ -318,23 +279,30 @@ interface IssueDetailProps {
// IssueDetail
// ---------------------------------------------------------------------------
export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailProps) {
export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
const id = issueId;
const router = useRouter();
const user = useAuthStore((s) => s.user);
const { getActorName } = useActorName();
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorName, getActorInitials } = useActorName();
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_issue_detail_layout",
});
const sidebarRef = usePanelRef();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [issue, setIssue] = useState<Issue | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState("");
const [commentEmpty, setCommentEmpty] = useState(true);
const commentEditorRef = useRef<RichTextEditorRef>(null);
const [submitting, setSubmitting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [editingDesc, setEditingDesc] = useState(false);
const [descDraft, setDescDraft] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// Watch the global issue store for real-time updates from other users/agents
const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id));
@ -358,10 +326,9 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
.finally(() => setLoading(false));
}, [id]);
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!commentText.trim() || submitting || !user) return;
const content = commentText.trim();
const handleSubmitComment = async () => {
const content = commentEditorRef.current?.getMarkdown()?.trim();
if (!content || submitting || !user) return;
const tempId = "temp-" + Date.now();
const tempComment: Comment = {
id: tempId,
@ -374,7 +341,8 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
updated_at: new Date().toISOString(),
};
setComments((prev) => [...prev, tempComment]);
setCommentText("");
commentEditorRef.current?.clearContent();
setCommentEmpty(true);
setSubmitting(true);
try {
const comment = await api.createComment(id, content);
@ -490,28 +458,186 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
}
return (
<div className="flex flex-1 min-h-0">
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="content" minSize="50%">
{/* LEFT: Content area */}
<div className="flex-1 overflow-y-auto">
<div className="flex h-full flex-col">
{/* Header bar */}
{showBreadcrumb !== false && (
<div className="sticky top-0 z-10 flex h-11 items-center justify-between border-b bg-background px-6 text-sm">
<div className="flex items-center gap-1.5">
<Link
href="/issues"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Issues
</Link>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span className="truncate text-muted-foreground">{issue.id.slice(0, 8)}</span>
</div>
<AlertDialog>
<AlertDialogTrigger
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>
<div className="flex h-12 shrink-0 items-center justify-between border-b bg-background px-4 text-sm">
<div className="flex items-center gap-1.5">
<Link
href="/issues"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Issues
</Link>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span className="truncate text-muted-foreground">{issue.id.slice(0, 8)}</span>
</div>
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-auto">
{/* Status */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
Status
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{ALL_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onClick={() => handleUpdateField({ status: s })}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
{issue.status === s && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Priority */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PriorityIcon priority={issue.priority} />
Priority
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{PRIORITY_ORDER.map((p) => (
<DropdownMenuItem
key={p}
onClick={() => handleUpdateField({ priority: p })}
>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
{issue.priority === p && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Assignee */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<UserMinus className="h-3.5 w-3.5" />
Assignee
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
{!issue.assignee_type && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
{members.map((m) => (
<DropdownMenuItem
key={m.user_id}
onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
{m.name}
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
{agents.map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
{a.name}
{issue.assignee_type === "agent" && issue.assignee_id === a.id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Due date */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Calendar className="h-3.5 w-3.5" />
Due date
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: new Date().toISOString() })}>
Today
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 1);
handleUpdateField({ due_date: d.toISOString() });
}}>
Tomorrow
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 7);
handleUpdateField({ due_date: d.toISOString() });
}}>
Next week
</DropdownMenuItem>
{issue.due_date && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: null })}>
Clear date
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
{/* Copy link */}
<DropdownMenuItem onClick={() => {
navigator.clipboard.writeText(window.location.href);
toast.success("Link copied");
}}>
<Link2 className="h-3.5 w-3.5" />
Copy link
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Delete */}
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete issue
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={sidebarOpen ? "secondary" : "ghost"}
size="icon-xs"
className={sidebarOpen ? "" : "text-muted-foreground"}
onClick={() => {
const panel = sidebarRef.current;
if (!panel) return;
if (panel.isCollapsed()) panel.expand();
else panel.collapse();
}}
>
<PanelRight className="h-4 w-4" />
</Button>
</div>
{/* Delete confirmation dialog (controlled by state) */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete issue</AlertDialogTitle>
@ -532,9 +658,9 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
</AlertDialogContent>
</AlertDialog>
</div>
)}
{/* Content */}
{/* Content — scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-3xl px-8 py-8">
<div className="mb-1 text-sm text-muted-foreground">{issue.id.slice(0, 8)}</div>
@ -566,33 +692,13 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
</h1>
)}
{editingDesc ? (
<Textarea
autoFocus
value={descDraft}
onChange={(e) => setDescDraft(e.target.value)}
onBlur={() => {
handleUpdateField({ description: descDraft.trim() || undefined });
setEditingDesc(false);
}}
onKeyDown={(e) => {
if (e.key === "Escape") setEditingDesc(false);
}}
rows={4}
className="mt-5 text-sm leading-relaxed resize-none"
/>
) : (
<div
className="mt-5 text-sm leading-relaxed whitespace-pre-wrap cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
onClick={() => { setDescDraft(issue.description || ""); setEditingDesc(true); }}
>
{issue.description ? (
<span className="text-foreground/85">{issue.description}</span>
) : (
<span className="text-muted-foreground">Add description...</span>
)}
</div>
)}
<RichTextEditor
defaultValue={issue.description || ""}
placeholder="Add description..."
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
debounceMs={1500}
className="mt-5"
/>
<div className="space-y-4 mt-4">
<AcceptanceCriteriaEditor
@ -670,8 +776,8 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
/>
</form>
) : (
<div className="mt-2 pl-9.5 text-sm leading-relaxed text-foreground/85 whitespace-pre-wrap">
{comment.content}
<div className="mt-2 pl-9.5 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{comment.content}</Markdown>
</div>
)}
</div>
@ -680,63 +786,197 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
</div>
{/* Comment input */}
<form onSubmit={handleSubmitComment} className="mt-2 border-t pt-4">
<div className="flex items-center gap-2">
<Input
type="text"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
<div className="mt-4 rounded-md border bg-muted/30">
<div className="min-h-20 max-h-48 overflow-y-auto px-3 py-2">
<RichTextEditor
ref={commentEditorRef}
placeholder="Leave a comment..."
className="flex-1 text-sm"
onUpdate={(md) => setCommentEmpty(!md.trim())}
onSubmit={handleSubmitComment}
debounceMs={100}
/>
</div>
<div className="flex items-center justify-end px-2 pb-2">
<Button
type="submit"
size="icon"
disabled={!commentText.trim() || submitting}
size="icon-xs"
disabled={commentEmpty || submitting}
onClick={handleSubmitComment}
>
<Send className="h-3.5 w-3.5" />
<ArrowUp className="h-3.5 w-3.5" />
</Button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
id="sidebar"
defaultSize={320}
minSize={260}
maxSize={420}
collapsible
groupResizeBehavior="preserve-pixel-size"
panelRef={sidebarRef}
onResize={(size) => setSidebarOpen(size.inPixels > 0)}
>
{/* RIGHT: Properties sidebar */}
<div className="w-60 shrink-0 overflow-y-auto border-l">
<div className="overflow-y-auto border-l h-full">
<div className="p-4">
<div className="mb-2 text-xs font-medium text-muted-foreground">
Properties
</div>
<div className="space-y-0.5">
{/* Status */}
<PropRow label="Status">
<StatusPicker status={issue.status} onUpdate={handleUpdateField} />
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{STATUS_CONFIG[issue.status].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuRadioGroup value={issue.status} onValueChange={(v) => handleUpdateField({ status: v as IssueStatus })}>
{ALL_STATUSES.map((s) => (
<DropdownMenuRadioItem key={s} value={s}>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Priority */}
<PropRow label="Priority">
<PriorityPicker priority={issue.priority} onUpdate={handleUpdateField} />
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<PriorityIcon priority={issue.priority} className="shrink-0" />
<span className="truncate">{PRIORITY_CONFIG[issue.priority].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuRadioGroup value={issue.priority} onValueChange={(v) => handleUpdateField({ priority: v as IssuePriority })}>
{PRIORITY_ORDER.map((p) => (
<DropdownMenuRadioItem key={p} value={p}>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Assignee */}
<PropRow label="Assignee">
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={handleUpdateField}
/>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
{issue.assignee_type && issue.assignee_id ? (
<>
<div className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4 ${
issue.assignee_type === "agent" ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"
}`}>
{issue.assignee_type === "agent" ? <Bot className="size-2.5" /> : getActorInitials(issue.assignee_type, issue.assignee_id)}
</div>
<span className="truncate">{getActorName(issue.assignee_type, issue.assignee_id)}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
</DropdownMenuItem>
{members.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Members</DropdownMenuLabel>
{members.map((m) => (
<DropdownMenuItem key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
{m.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
{agents.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Agents</DropdownMenuLabel>
{agents.map((a) => (
<DropdownMenuItem key={a.id} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
{a.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Due date */}
<PropRow label="Due date">
<DueDatePicker dueDate={issue.due_date} onUpdate={handleUpdateField} />
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<Calendar className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{issue.due_date ? (
<span className={new Date(issue.due_date) < new Date() ? "text-destructive" : ""}>
{shortDate(issue.due_date)}
</span>
) : (
<span className="text-muted-foreground">None</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: new Date().toISOString() })}>
Today
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 1);
handleUpdateField({ due_date: d.toISOString() });
}}>
Tomorrow
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 7);
handleUpdateField({ due_date: d.toISOString() });
}}>
Next week
</DropdownMenuItem>
{issue.due_date && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: null })}>
Clear date
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Created by */}
<PropRow label="Created by">
<ActorAvatar
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
/>
<span>{getActorName(issue.creator_type, issue.creator_id)}</span>
<span className="truncate">{getActorName(issue.creator_type, issue.creator_id)}</span>
</PropRow>
</div>
@ -750,6 +990,7 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
</div>
</div>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View file

@ -44,7 +44,7 @@ export function IssuesHeader() {
const togglePriorityFilter = useIssueViewStore((s) => s.togglePriorityFilter);
return (
<div className="flex shrink-0 items-center justify-between px-4 py-2">
<div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-2">
{/* Status filter */}
<DropdownMenu>
@ -72,7 +72,7 @@ export function IssuesHeader() {
{ALL_STATUSES.map((s) => (
<DropdownMenuCheckboxItem
key={s}
checked={statusFilters.includes(s)}
checked={statusFilters.length === 0 || statusFilters.includes(s)}
onCheckedChange={() => toggleStatusFilter(s)}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
@ -109,7 +109,7 @@ export function IssuesHeader() {
{PRIORITY_ORDER.map((p) => (
<DropdownMenuCheckboxItem
key={p}
checked={priorityFilters.includes(p)}
checked={priorityFilters.length === 0 || priorityFilters.includes(p)}
onCheckedChange={() => togglePriorityFilter(p)}
>
<PriorityIcon priority={p} />

View file

@ -3,7 +3,7 @@
import { useCallback, useMemo } from "react";
import { toast } from "sonner";
import { ChevronRight } from "lucide-react";
import type { IssueStatus } from "@multica/types";
import type { IssueStatus } from "@/shared/types";
import { Skeleton } from "@/components/ui/skeleton";
import { useIssueStore } from "@/features/issues/store";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
@ -68,11 +68,11 @@ export function IssuesPage() {
if (loading) {
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex shrink-0 items-center justify-between border-b px-4 py-2">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
@ -92,7 +92,7 @@ export function IssuesPage() {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header 1: Workspace breadcrumb */}
<div className="flex shrink-0 items-center gap-1.5 border-b px-4 py-2">
<div className="flex h-12 shrink-0 items-center gap-1.5 border-b px-4">
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
<span className="text-sm text-muted-foreground">
{workspace?.name ?? "Workspace"}

View file

@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import type { Issue } from "@multica/types";
import type { Issue } from "@/shared/types";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon";

View file

@ -1,6 +1,6 @@
"use client";
import type { Issue } from "@multica/types";
import type { Issue } from "@/shared/types";
import { STATUS_ORDER, STATUS_CONFIG } from "@/features/issues/config";
import { StatusIcon } from "./status-icon";
import { ListRow } from "./list-row";
@ -9,14 +9,14 @@ export function ListView({ issues }: { issues: Issue[] }) {
const groupOrder = STATUS_ORDER.filter((s) => s !== "cancelled");
return (
<div className="overflow-y-auto">
<div className="flex-1 min-h-0 overflow-y-auto">
{groupOrder.map((status) => {
const cfg = STATUS_CONFIG[status];
const filtered = issues.filter((i) => i.status === status);
if (filtered.length === 0) return null;
return (
<div key={status}>
<div className="flex h-8 items-center gap-2 border-b px-4">
<div className="flex h-12 items-center gap-2 border-b px-4">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{cfg.label}</span>
<span className="text-xs text-muted-foreground">

View file

@ -2,7 +2,7 @@
import { useState } from "react";
import { Bot, UserMinus } from "lucide-react";
import type { IssueAssigneeType, UpdateIssueRequest } from "@multica/types";
import type { IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import {
PropertyPicker,
@ -69,7 +69,7 @@ export function AssigneePicker({
getActorInitials(assigneeType, assigneeId)
)}
</div>
<span>{triggerLabel}</span>
<span className="truncate">{triggerLabel}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>

View file

@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { CalendarDays } from "lucide-react";
import type { UpdateIssueRequest } from "@/shared/types";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
export function DueDatePicker({
dueDate,
onUpdate,
}: {
dueDate: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const [open, setOpen] = useState(false);
const date = dueDate ? new Date(dueDate) : undefined;
const isOverdue = date ? date < new Date() : false;
return (
<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">
<CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
{date ? (
<span className={isOverdue ? "text-destructive" : ""}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
) : (
<span className="text-muted-foreground">Due date</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={(d: Date | undefined) => {
onUpdate({ due_date: d ? d.toISOString() : null });
setOpen(false);
}}
/>
{date && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
onUpdate({ due_date: null });
setOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
Clear date
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}

View file

@ -2,3 +2,4 @@ export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./proper
export { StatusPicker } from "./status-picker";
export { PriorityPicker } from "./priority-picker";
export { AssigneePicker } from "./assignee-picker";
export { DueDatePicker } from "./due-date-picker";

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { IssuePriority, UpdateIssueRequest } from "@multica/types";
import type { IssuePriority, UpdateIssueRequest } from "@/shared/types";
import { PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { PriorityIcon } from "../priority-icon";
import { PropertyPicker, PickerItem } from "./property-picker";
@ -23,8 +23,8 @@ export function PriorityPicker({
width="w-44"
trigger={
<>
<PriorityIcon priority={priority} />
<span>{cfg.label}</span>
<PriorityIcon priority={priority} className="shrink-0" />
<span className="truncate">{cfg.label}</span>
</>
}
>

View file

@ -48,7 +48,7 @@ export function PropertyPicker({
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
{trigger}
</PopoverTrigger>
<PopoverContent align={align} className={`${width} gap-0 p-0`}>

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { IssueStatus, UpdateIssueRequest } from "@multica/types";
import type { IssueStatus, UpdateIssueRequest } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
import { StatusIcon } from "../status-icon";
import { PropertyPicker, PickerItem } from "./property-picker";
@ -23,8 +23,8 @@ export function StatusPicker({
width="w-44"
trigger={
<>
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span>{cfg.label}</span>
<StatusIcon status={status} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{cfg.label}</span>
</>
}
>

View file

@ -1,4 +1,4 @@
import type { IssuePriority } from "@multica/types";
import type { IssuePriority } from "@/shared/types";
import { PRIORITY_CONFIG } from "@/features/issues/config";
export function PriorityIcon({

View file

@ -1,4 +1,4 @@
import type { IssueStatus } from "@multica/types";
import type { IssueStatus } from "@/shared/types";
import { STATUS_CONFIG } from "@/features/issues/config";
// ---------------------------------------------------------------------------

View file

@ -1,4 +1,4 @@
import type { IssuePriority } from "@multica/types";
import type { IssuePriority } from "@/shared/types";
export const PRIORITY_ORDER: IssuePriority[] = [
"urgent",

View file

@ -1,4 +1,4 @@
import type { IssueStatus } from "@multica/types";
import type { IssueStatus } from "@/shared/types";
export const STATUS_ORDER: IssueStatus[] = [
"backlog",

View file

@ -1,7 +1,7 @@
"use client";
import { create } from "zustand";
import type { Issue } from "@multica/types";
import type { Issue } from "@/shared/types";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";

View file

@ -2,7 +2,8 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { IssueStatus, IssuePriority } from "@multica/types";
import type { IssueStatus, IssuePriority } from "@/shared/types";
import { ALL_STATUSES, PRIORITY_ORDER } from "@/features/issues/config";
export type ViewMode = "board" | "list";
@ -13,6 +14,7 @@ interface IssueViewState {
setViewMode: (mode: ViewMode) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
hideStatus: (status: IssueStatus) => void;
clearFilters: () => void;
}
@ -25,16 +27,30 @@ export const useIssueViewStore = create<IssueViewState>()(
setViewMode: (mode) => set({ viewMode: mode }),
toggleStatusFilter: (status) =>
set((state) => ({
statusFilters: state.statusFilters.includes(status)
set((state) => {
if (state.statusFilters.length === 0) {
return { statusFilters: ALL_STATUSES.filter((s) => s !== status) };
}
const next = state.statusFilters.includes(status)
? state.statusFilters.filter((s) => s !== status)
: [...state.statusFilters, status],
})),
: [...state.statusFilters, status];
return { statusFilters: next.length >= ALL_STATUSES.length ? [] : next };
}),
togglePriorityFilter: (priority) =>
set((state) => ({
priorityFilters: state.priorityFilters.includes(priority)
set((state) => {
if (state.priorityFilters.length === 0) {
return { priorityFilters: PRIORITY_ORDER.filter((p) => p !== priority) };
}
const next = state.priorityFilters.includes(priority)
? state.priorityFilters.filter((p) => p !== priority)
: [...state.priorityFilters, priority],
: [...state.priorityFilters, priority];
return { priorityFilters: next.length >= PRIORITY_ORDER.length ? [] : next };
}),
hideStatus: (status) =>
set((state) => ({
statusFilters: state.statusFilters.length === 0
? ALL_STATUSES.filter((s) => s !== status)
: state.statusFilters.filter((s) => s !== status),
})),
clearFilters: () => set({ statusFilters: [], priorityFilters: [] }),
}),

View file

@ -1,51 +1,110 @@
"use client";
import { useState } from "react";
import { useState, useRef } from "react";
import { Bot, CalendarDays, ChevronRight, Maximize2, Minimize2, UserMinus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "@multica/types";
import { STATUS_CONFIG, ALL_STATUSES, PRIORITY_CONFIG, PRIORITY_ORDER } from "@/features/issues/config";
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "@/shared/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { StatusIcon, PriorityIcon, AssigneePicker } from "@/features/issues/components";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import { api } from "@/shared/api";
export function CreateIssueModal({ onClose }: { onClose: () => void }) {
// ---------------------------------------------------------------------------
// Pill trigger — shared rounded-full button style for toolbar
// ---------------------------------------------------------------------------
function PillButton({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
"hover:bg-accent/60 transition-colors cursor-pointer",
className,
)}
{...props}
>
{children}
</button>
);
}
// ---------------------------------------------------------------------------
// CreateIssueModal
// ---------------------------------------------------------------------------
export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorName, getActorInitials } = useActorName();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<IssueStatus>("todo");
const descEditorRef = useRef<RichTextEditorRef>(null);
const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || "todo");
const [priority, setPriority] = useState<IssuePriority>("none");
const [submitting, setSubmitting] = useState(false);
const [assigneeType, setAssigneeType] = useState<IssueAssigneeType | undefined>();
const [assigneeId, setAssigneeId] = useState<string | undefined>();
const [dueDate, setDueDate] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// Assignee popover
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeFilter, setAssigneeFilter] = useState("");
// Due date popover
const [dueDateOpen, setDueDateOpen] = useState(false);
const assigneeQuery = assigneeFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery));
const assigneeLabel =
assigneeType && assigneeId
? getActorName(assigneeType, assigneeId)
: "Assignee";
const dueDateObj = dueDate ? new Date(dueDate) : undefined;
const handleSubmit = async () => {
if (!title.trim()) return;
if (!title.trim() || submitting) return;
setSubmitting(true);
try {
const issue = await api.createIssue({
title: title.trim(),
description: description.trim() || undefined,
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
status,
priority,
assignee_type: assigneeType,
assignee_id: assigneeId,
due_date: dueDate || undefined,
});
useIssueStore.getState().addIssue(issue);
onClose();
@ -58,12 +117,44 @@ export function CreateIssueModal({ onClose }: { onClose: () => void }) {
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New Issue</DialogTitle>
<DialogDescription className="sr-only">Create a new issue for the workspace.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<DialogContent
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2",
"!transition-all !duration-300 !ease-out",
isExpanded
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
)}
>
<DialogTitle className="sr-only">New Issue</DialogTitle>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">New issue</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
<button
onClick={onClose}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Title */}
<div className="px-5 pb-2 shrink-0">
<Input
autoFocus
type="text"
@ -76,55 +167,211 @@ export function CreateIssueModal({ onClose }: { onClose: () => void }) {
}
}}
placeholder="Issue title"
className="border-none shadow-none px-0 text-lg font-semibold focus-visible:ring-0"
/>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add description..."
rows={3}
className="resize-none"
/>
<div className="flex items-center gap-3 flex-wrap">
<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) => (
<SelectItem key={s} value={s}>{STATUS_CONFIG[s].label}</SelectItem>
))}
</SelectContent>
</Select>
<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) => (
<SelectItem key={p} value={p}>{PRIORITY_CONFIG[p].label}</SelectItem>
))}
</SelectContent>
</Select>
<AssigneePicker
assigneeType={assigneeType ?? null}
assigneeId={assigneeId ?? null}
onUpdate={(updates) => {
setAssigneeType(updates.assignee_type ?? undefined);
setAssigneeId(updates.assignee_id ?? undefined);
}}
/>
</div>
</div>
<DialogFooter>
<Button
onClick={handleSubmit}
disabled={!title.trim() || submitting}
>
{/* Description — takes remaining space */}
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<RichTextEditor
ref={descEditorRef}
placeholder="Add description..."
/>
</div>
{/* Property toolbar */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
{/* Status */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<StatusIcon status={status} className="size-3.5" />
<span>{STATUS_CONFIG[status].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{ALL_STATUSES.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<StatusIcon status={s} className="size-3.5" />
<span>{STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Priority */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<PriorityIcon priority={priority} />
<span>{PRIORITY_CONFIG[priority].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => setPriority(p)}>
<PriorityIcon priority={p} />
<span>{PRIORITY_CONFIG[p].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Assignee — Popover for search support */}
<Popover open={assigneeOpen} onOpenChange={(v) => { setAssigneeOpen(v); if (!v) setAssigneeFilter(""); }}>
<PopoverTrigger
render={
<PillButton>
{assigneeType && assigneeId ? (
<>
<div
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4",
assigneeType === "agent" ? "bg-info/10 text-info" : "bg-muted text-muted-foreground",
)}
>
{assigneeType === "agent" ? <Bot className="size-2.5" /> : getActorInitials(assigneeType, assigneeId)}
</div>
<span>{assigneeLabel}</span>
</>
) : (
<span className="text-muted-foreground">Assignee</span>
)}
</PillButton>
}
/>
<PopoverContent align="start" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={assigneeFilter}
onChange={(e) => setAssigneeFilter(e.target.value)}
placeholder="Assign to..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
{/* Unassigned */}
<button
type="button"
onClick={() => {
setAssigneeType(undefined);
setAssigneeId(undefined);
setAssigneeOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</button>
{/* Members */}
{filteredMembers.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Members</div>
{filteredMembers.map((m) => (
<button
type="button"
key={m.user_id}
onClick={() => {
setAssigneeType("member");
setAssigneeId(m.user_id);
setAssigneeOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<span>{m.name}</span>
</button>
))}
</>
)}
{/* Agents */}
{filteredAgents.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agents</div>
{filteredAgents.map((a) => (
<button
type="button"
key={a.id}
onClick={() => {
setAssigneeType("agent");
setAssigneeId(a.id);
setAssigneeOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
<span>{a.name}</span>
</button>
))}
</>
)}
{filteredMembers.length === 0 && filteredAgents.length === 0 && assigneeFilter && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">No results</div>
)}
</div>
</PopoverContent>
</Popover>
{/* Due date */}
<Popover open={dueDateOpen} onOpenChange={setDueDateOpen}>
<PopoverTrigger
render={
<PillButton>
<CalendarDays className="size-3.5 text-muted-foreground" />
{dueDateObj ? (
<span>{dueDateObj.toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>
) : (
<span className="text-muted-foreground">Due date</span>
)}
</PillButton>
}
/>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDateObj}
onSelect={(d: Date | undefined) => {
setDueDate(d ? d.toISOString() : null);
setDueDateOpen(false);
}}
/>
{dueDateObj && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
setDueDate(null);
setDueDateOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
Clear date
</Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
{/* Footer */}
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);

View file

@ -6,13 +6,14 @@ import { CreateIssueModal } from "./create-issue";
export function ModalRegistry() {
const modal = useModalStore((s) => s.modal);
const data = useModalStore((s) => s.data);
const close = useModalStore((s) => s.close);
switch (modal) {
case "create-workspace":
return <CreateWorkspaceModal onClose={close} />;
case "create-issue":
return <CreateIssueModal onClose={close} />;
return <CreateIssueModal onClose={close} data={data} />;
default:
return null;
}

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect } from "react";
import type { WSEventType } from "@multica/types";
import type { WSEventType } from "@/shared/types";
import { useWS } from "./provider";
type EventHandler = (payload: unknown) => void;

View file

@ -9,8 +9,8 @@ import {
useCallback,
type ReactNode,
} from "react";
import { WSClient } from "@multica/sdk";
import type { WSEventType } from "@multica/types";
import { WSClient } from "@/shared/api";
import type { WSEventType } from "@/shared/types";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { createLogger } from "@/shared/logger";

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect } from "react";
import type { WSClient } from "@multica/sdk";
import type { WSClient } from "@/shared/api";
import { toast } from "sonner";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
@ -22,7 +22,7 @@ import type {
MemberAddedPayload,
MemberUpdatedPayload,
MemberRemovedPayload,
} from "@multica/types";
} from "@/shared/types";
const logger = createLogger("realtime-sync");

View file

@ -13,7 +13,7 @@ import {
XCircle,
Zap,
} from "lucide-react";
import type { AgentRuntime, RuntimeUsage, RuntimePingStatus } from "@multica/types";
import type { AgentRuntime, RuntimeUsage, RuntimePingStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";

View file

@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import {
Sparkles,
Plus,
@ -10,8 +11,9 @@ import {
FolderOpen,
AlertCircle,
X,
Download,
} from "lucide-react";
import type { Skill, CreateSkillRequest, UpdateSkillRequest } from "@multica/types";
import type { Skill, CreateSkillRequest, UpdateSkillRequest } from "@/shared/types";
import {
Dialog,
DialogContent,
@ -20,10 +22,16 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
@ -36,22 +44,47 @@ import { useWSEvent } from "@/features/realtime";
function CreateSkillDialog({
onClose,
onCreate,
onImport,
}: {
onClose: () => void;
onCreate: (data: CreateSkillRequest) => Promise<void>;
onImport: (url: string) => Promise<void>;
}) {
const [tab, setTab] = useState<"create" | "import">("create");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [creating, setCreating] = useState(false);
const [importUrl, setImportUrl] = useState("");
const [loading, setLoading] = useState(false);
const [importError, setImportError] = useState("");
const handleSubmit = async () => {
const detectedSource = (() => {
const url = importUrl.trim().toLowerCase();
if (url.includes("clawhub.ai")) return "clawhub" as const;
if (url.includes("skills.sh")) return "skills.sh" as const;
return null;
})();
const handleCreate = async () => {
if (!name.trim()) return;
setCreating(true);
setLoading(true);
try {
await onCreate({ name: name.trim(), description: description.trim() });
onClose();
} catch {
setCreating(false);
setLoading(false);
}
};
const handleImport = async () => {
if (!importUrl.trim()) return;
setLoading(true);
setImportError("");
try {
await onImport(importUrl.trim());
onClose();
} catch (err) {
setImportError(err instanceof Error ? err.message : "Import failed");
setLoading(false);
}
};
@ -59,42 +92,94 @@ function CreateSkillDialog({
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Skill</DialogTitle>
<DialogTitle>Add Skill</DialogTitle>
<DialogDescription>
Create a reusable skill that can be assigned to agents.
Create a new skill or import from ClawHub / Skills.sh.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Code Review, Bug Triage"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does"
className="mt-1"
/>
</div>
</div>
<Tabs value={tab} onValueChange={(v) => setTab(v as "create" | "import")}>
<TabsList className="w-full">
<TabsTrigger value="create" className="flex-1">
<Plus className="mr-1.5 h-3 w-3" />
Create
</TabsTrigger>
<TabsTrigger value="import" className="flex-1">
<Download className="mr-1.5 h-3 w-3" />
Import
</TabsTrigger>
</TabsList>
<TabsContent value="create" className="space-y-4 mt-4">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Code Review, Bug Triage"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does"
className="mt-1"
/>
</div>
</TabsContent>
<TabsContent value="import" className="space-y-4 mt-4">
<div>
<Label className="text-xs text-muted-foreground">Skill URL</Label>
<Input
autoFocus
type="text"
value={importUrl}
onChange={(e) => { setImportUrl(e.target.value); setImportError(""); }}
placeholder="https://clawhub.ai/owner/skill-name"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleImport()}
/>
<div className="flex items-center gap-2 mt-1.5">
{detectedSource ? (
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-2 py-0.5 text-xs font-medium">
{detectedSource === "clawhub" ? "ClawHub" : "Skills.sh"}
</span>
) : (
<p className="text-xs text-muted-foreground">
Supports <span className="font-medium">clawhub.ai</span> and <span className="font-medium">skills.sh</span>
</p>
)}
</div>
</div>
{importError && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{importError}
</div>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={creating || !name.trim()}>
{creating ? "Creating..." : "Create"}
</Button>
{tab === "create" ? (
<Button onClick={handleCreate} disabled={loading || !name.trim()}>
{loading ? "Creating..." : "Create"}
</Button>
) : (
<Button onClick={handleImport} disabled={loading || !importUrl.trim()}>
<Download className="mr-1.5 h-3 w-3" />
{loading ? "Importing..." : "Import"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
@ -423,6 +508,9 @@ export default function SkillsPage() {
const removeSkill = useWorkspaceStore((s) => s.removeSkill);
const [selectedId, setSelectedId] = useState<string>("");
const [showCreate, setShowCreate] = useState(false);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_skills_layout",
});
useEffect(() => {
if (skills.length > 0 && !selectedId) {
@ -444,6 +532,12 @@ export default function SkillsPage() {
setSelectedId(skill.id);
};
const handleImport = async (url: string) => {
const skill = await api.importSkill({ url });
upsertSkill(skill);
setSelectedId(skill.id);
};
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
const updated = await api.updateSkill(id, data);
upsertSkill(updated);
@ -469,80 +563,92 @@ export default function SkillsPage() {
}
return (
<div className="flex flex-1 min-h-0">
{/* Left column — skill list */}
<div className="w-72 shrink-0 overflow-y-auto border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Skills</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
<ResizablePanelGroup
orientation="horizontal"
className="flex-1 min-h-0"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
{/* Left column — skill list */}
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Skills</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
{skills.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No skills yet</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Skills define reusable instructions for agents.
</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Skill
</Button>
</div>
) : (
<div className="divide-y">
{skills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
isSelected={skill.id === selectedId}
onClick={() => setSelectedId(skill.id)}
/>
))}
</div>
)}
</div>
{skills.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No skills yet</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Skills define reusable instructions for agents.
</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Skill
</Button>
</div>
) : (
<div className="divide-y">
{skills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
isSelected={skill.id === selectedId}
onClick={() => setSelectedId(skill.id)}
/>
))}
</div>
)}
</div>
</ResizablePanel>
{/* Right column — skill detail */}
<div className="flex-1 overflow-hidden">
{selected ? (
<SkillDetail
key={selected.id}
skill={selected}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Sparkles className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select a skill to view details</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Skill
</Button>
</div>
)}
</div>
<ResizableHandle />
<ResizablePanel id="detail" minSize="50%">
{/* Right column — skill detail */}
<div className="flex-1 overflow-hidden h-full">
{selected ? (
<SkillDetail
key={selected.id}
skill={selected}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Sparkles className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select a skill to view details</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Skill
</Button>
</div>
)}
</div>
</ResizablePanel>
{showCreate && (
<CreateSkillDialog
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
onImport={handleImport}
/>
)}
</div>
</ResizablePanelGroup>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { create } from "zustand";
import type { Workspace, MemberWithUser, Agent, Skill } from "@multica/types";
import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { api } from "@/shared/api";

View file

@ -1,11 +1,6 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@multica/sdk",
"@multica/types",
"@multica/utils",
],
};
export default nextConfig;

View file

@ -16,9 +16,12 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@multica/sdk": "workspace:*",
"@multica/types": "workspace:*",
"@multica/utils": "workspace:*",
"@tiptap/extension-link": "^3.20.5",
"@tiptap/extension-placeholder": "^3.20.5",
"@tiptap/extension-typography": "^3.20.5",
"@tiptap/pm": "^3.20.5",
"@tiptap/react": "^3.20.5",
"@tiptap/starter-kit": "^3.20.5",
"@types/linkify-it": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -42,6 +45,7 @@
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tiptap-markdown": "^0.9.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zustand": "catalog:"

View file

@ -0,0 +1,424 @@
import type {
Issue,
CreateIssueRequest,
UpdateIssueRequest,
ListIssuesResponse,
UpdateMeRequest,
CreateMemberRequest,
UpdateMemberRequest,
ListIssuesParams,
Agent,
CreateAgentRequest,
UpdateAgentRequest,
AgentTask,
AgentRuntime,
DaemonPairingSession,
ApproveDaemonPairingSessionRequest,
InboxItem,
Comment,
Workspace,
MemberWithUser,
User,
Skill,
CreateSkillRequest,
UpdateSkillRequest,
SetAgentSkillsRequest,
PersonalAccessToken,
CreatePersonalAccessTokenRequest,
CreatePersonalAccessTokenResponse,
RuntimeUsage,
RuntimePing,
} from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
export interface LoginResponse {
token: string;
user: User;
}
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
private workspaceId: string | null = null;
private logger: Logger;
constructor(baseUrl: string, options?: { logger?: Logger }) {
this.baseUrl = baseUrl;
this.logger = options?.logger ?? noopLogger;
}
setToken(token: string | null) {
this.token = token;
}
setWorkspaceId(id: string | null) {
this.workspaceId = id;
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const rid = crypto.randomUUID().slice(0, 8);
const start = Date.now();
const method = init?.method ?? "GET";
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Request-ID": rid,
...((init?.headers as Record<string, string>) ?? {}),
};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
if (this.workspaceId) {
headers["X-Workspace-ID"] = this.workspaceId;
}
this.logger.info(`${method} ${path}`, { rid });
const res = await fetch(`${this.baseUrl}${path}`, {
...init,
headers,
});
if (!res.ok) {
if (res.status === 401 && typeof window !== "undefined") {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
this.token = null;
this.workspaceId = null;
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
let message = `API error: ${res.status} ${res.statusText}`;
try {
const data = await res.json() as { error?: string };
if (typeof data.error === "string" && data.error) {
message = data.error;
}
} catch {
// Ignore non-JSON error bodies.
}
this.logger.error(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new Error(message);
}
this.logger.info(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
// Auth
async sendCode(email: string): Promise<void> {
await this.fetch("/auth/send-code", {
method: "POST",
body: JSON.stringify({ email }),
});
}
async verifyCode(email: string, code: string): Promise<LoginResponse> {
return this.fetch("/auth/verify-code", {
method: "POST",
body: JSON.stringify({ email, code }),
});
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}
async updateMe(data: UpdateMeRequest): Promise<User> {
return this.fetch("/api/me", {
method: "PATCH",
body: JSON.stringify(data),
});
}
// Issues
async listIssues(params?: ListIssuesParams): Promise<ListIssuesResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", String(params.limit));
if (params?.offset) search.set("offset", String(params.offset));
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
if (params?.status) search.set("status", params.status);
if (params?.priority) search.set("priority", params.priority);
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
return this.fetch(`/api/issues?${search}`);
}
async getIssue(id: string): Promise<Issue> {
return this.fetch(`/api/issues/${id}`);
}
async createIssue(data: CreateIssueRequest): Promise<Issue> {
const search = new URLSearchParams();
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
return this.fetch(`/api/issues?${search}`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateIssue(id: string, data: UpdateIssueRequest): Promise<Issue> {
return this.fetch(`/api/issues/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteIssue(id: string): Promise<void> {
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
}
// Comments
async listComments(issueId: string): Promise<Comment[]> {
return this.fetch(`/api/issues/${issueId}/comments`);
}
async createComment(issueId: string, content: string, type?: string): Promise<Comment> {
return this.fetch(`/api/issues/${issueId}/comments`, {
method: "POST",
body: JSON.stringify({ content, type: type ?? "comment" }),
});
}
async updateComment(commentId: string, content: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
body: JSON.stringify({ content }),
});
}
async deleteComment(commentId: string): Promise<void> {
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
}
// Agents
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
const search = new URLSearchParams();
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
return this.fetch(`/api/agents?${search}`);
}
async getAgent(id: string): Promise<Agent> {
return this.fetch(`/api/agents/${id}`);
}
async createAgent(data: CreateAgentRequest): Promise<Agent> {
return this.fetch("/api/agents", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
return this.fetch(`/api/agents/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteAgent(id: string): Promise<void> {
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
}
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
const search = new URLSearchParams();
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
return this.fetch(`/api/runtimes?${search}`);
}
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`);
}
async pingRuntime(runtimeId: string): Promise<RuntimePing> {
return this.fetch(`/api/runtimes/${runtimeId}/ping`, { method: "POST" });
}
async getPingResult(runtimeId: string, pingId: string): Promise<RuntimePing> {
return this.fetch(`/api/runtimes/${runtimeId}/ping/${pingId}`);
}
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
return this.fetch(`/api/agents/${agentId}/tasks`);
}
async getDaemonPairingSession(token: string): Promise<DaemonPairingSession> {
return this.fetch(`/api/daemon/pairing-sessions/${token}`);
}
async approveDaemonPairingSession(
token: string,
data: ApproveDaemonPairingSessionRequest,
): Promise<DaemonPairingSession> {
return this.fetch(`/api/daemon/pairing-sessions/${token}/approve`, {
method: "POST",
body: JSON.stringify(data),
});
}
// Inbox
async listInbox(): Promise<InboxItem[]> {
return this.fetch("/api/inbox");
}
async markInboxRead(id: string): Promise<InboxItem> {
return this.fetch(`/api/inbox/${id}/read`, { method: "POST" });
}
async archiveInbox(id: string): Promise<InboxItem> {
return this.fetch(`/api/inbox/${id}/archive`, { method: "POST" });
}
async getUnreadInboxCount(): Promise<{ count: number }> {
return this.fetch("/api/inbox/unread-count");
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
}
async archiveAllInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all", { method: "POST" });
}
async archiveAllReadInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
}
async archiveCompletedInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
}
// Workspaces
async listWorkspaces(): Promise<Workspace[]> {
return this.fetch("/api/workspaces");
}
async getWorkspace(id: string): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`);
}
async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise<Workspace> {
return this.fetch("/api/workspaces", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown> }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
// Members
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
return this.fetch(`/api/workspaces/${workspaceId}/members`);
}
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<MemberWithUser> {
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise<MemberWithUser> {
return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async deleteMember(workspaceId: string, memberId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
method: "DELETE",
});
}
async leaveWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/leave`, {
method: "POST",
});
}
async deleteWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}`, {
method: "DELETE",
});
}
// Skills
async listSkills(): Promise<Skill[]> {
return this.fetch("/api/skills");
}
async getSkill(id: string): Promise<Skill> {
return this.fetch(`/api/skills/${id}`);
}
async createSkill(data: CreateSkillRequest): Promise<Skill> {
return this.fetch("/api/skills", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateSkill(id: string, data: UpdateSkillRequest): Promise<Skill> {
return this.fetch(`/api/skills/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteSkill(id: string): Promise<void> {
await this.fetch(`/api/skills/${id}`, { method: "DELETE" });
}
async importSkill(data: { url: string }): Promise<Skill> {
return this.fetch("/api/skills/import", {
method: "POST",
body: JSON.stringify(data),
});
}
async listAgentSkills(agentId: string): Promise<Skill[]> {
return this.fetch(`/api/agents/${agentId}/skills`);
}
async setAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
await this.fetch(`/api/agents/${agentId}/skills`, {
method: "PUT",
body: JSON.stringify(data),
});
}
// Personal Access Tokens
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
return this.fetch("/api/tokens");
}
async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise<CreatePersonalAccessTokenResponse> {
return this.fetch("/api/tokens", {
method: "POST",
body: JSON.stringify(data),
});
}
async revokePersonalAccessToken(id: string): Promise<void> {
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
}
}

View file

@ -1,5 +1,9 @@
import { ApiClient } from "@multica/sdk";
import { createLogger } from "./logger";
import { createLogger } from "@/shared/logger";
import { ApiClient } from "./client";
export { ApiClient } from "./client";
export type { LoginResponse } from "./client";
export { WSClient } from "./ws-client";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
@ -16,13 +20,4 @@ if (typeof window !== "undefined") {
api.setWorkspaceId(wsId);
}
api.setOnUnauthorized(() => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
});
}

View file

@ -0,0 +1,110 @@
import type { WSMessage, WSEventType } from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
type EventHandler = (payload: unknown) => void;
export class WSClient {
private ws: WebSocket | null = null;
private baseUrl: string;
private token: string | null = null;
private workspaceId: string | null = null;
private handlers = new Map<WSEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private hasConnectedBefore = false;
private onReconnectCallbacks = new Set<() => void>();
private logger: Logger;
constructor(url: string, options?: { logger?: Logger }) {
this.baseUrl = url;
this.logger = options?.logger ?? noopLogger;
}
setAuth(token: string, workspaceId: string) {
this.token = token;
this.workspaceId = workspaceId;
}
connect() {
const url = new URL(this.baseUrl);
if (this.token) url.searchParams.set("token", this.token);
if (this.workspaceId)
url.searchParams.set("workspace_id", this.workspaceId);
this.ws = new WebSocket(url.toString());
this.ws.onopen = () => {
this.logger.info("connected");
if (this.hasConnectedBefore) {
for (const cb of this.onReconnectCallbacks) {
try {
cb();
} catch {
// ignore reconnect callback errors
}
}
}
this.hasConnectedBefore = true;
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string) as WSMessage;
this.logger.debug("received", msg.type);
const eventHandlers = this.handlers.get(msg.type);
if (eventHandlers) {
for (const handler of eventHandlers) {
handler(msg.payload);
}
} else {
this.logger.debug("unhandled event", msg.type);
}
};
this.ws.onclose = () => {
this.logger.warn("disconnected, reconnecting in 3s");
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
};
this.ws.onerror = () => {
// Suppress — onclose handles reconnect; errors during StrictMode
// double-fire are expected in dev and harmless.
};
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// Remove handlers before close to prevent onclose from scheduling a reconnect
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
this.hasConnectedBefore = false;
}
on(event: WSEventType, handler: EventHandler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
return () => {
this.handlers.get(event)?.delete(handler);
};
}
onReconnect(callback: () => void) {
this.onReconnectCallbacks.add(callback);
return () => {
this.onReconnectCallbacks.delete(callback);
};
}
send(message: WSMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
}

View file

@ -0,0 +1,168 @@
export type AgentStatus = "idle" | "working" | "blocked" | "error" | "offline";
export type AgentRuntimeMode = "local" | "cloud";
export type AgentVisibility = "workspace" | "private";
export type AgentTriggerType = "on_assign" | "scheduled";
export interface RuntimeDevice {
id: string;
workspace_id: string;
daemon_id: string | null;
name: string;
runtime_mode: AgentRuntimeMode;
provider: string;
status: "online" | "offline";
device_info: string;
metadata: Record<string, unknown>;
last_seen_at: string | null;
created_at: string;
updated_at: string;
}
export type AgentRuntime = RuntimeDevice;
export interface AgentTool {
id: string;
name: string;
description: string;
auth_type: "oauth" | "api_key" | "none";
connected: boolean;
config: Record<string, unknown>;
}
export interface AgentTrigger {
id: string;
type: AgentTriggerType;
enabled: boolean;
config: Record<string, unknown>;
}
export interface AgentTask {
id: string;
agent_id: string;
runtime_id: string;
issue_id: string;
status: "queued" | "dispatched" | "running" | "completed" | "failed" | "cancelled";
priority: number;
dispatched_at: string | null;
started_at: string | null;
completed_at: string | null;
result: unknown;
error: string | null;
created_at: string;
}
export interface Agent {
id: string;
workspace_id: string;
runtime_id: string;
name: string;
description: string;
avatar_url: string | null;
runtime_mode: AgentRuntimeMode;
runtime_config: Record<string, unknown>;
visibility: AgentVisibility;
status: AgentStatus;
max_concurrent_tasks: number;
owner_id: string | null;
skills: Skill[];
tools: AgentTool[];
triggers: AgentTrigger[];
created_at: string;
updated_at: string;
}
export interface CreateAgentRequest {
name: string;
description?: string;
avatar_url?: string;
runtime_id: string;
runtime_config?: Record<string, unknown>;
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
tools?: AgentTool[];
triggers?: AgentTrigger[];
}
export interface UpdateAgentRequest {
name?: string;
description?: string;
avatar_url?: string;
runtime_id?: string;
runtime_config?: Record<string, unknown>;
visibility?: AgentVisibility;
status?: AgentStatus;
max_concurrent_tasks?: number;
tools?: AgentTool[];
triggers?: AgentTrigger[];
}
// Skills
export interface Skill {
id: string;
workspace_id: string;
name: string;
description: string;
content: string;
config: Record<string, unknown>;
files: SkillFile[];
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface SkillFile {
id: string;
skill_id: string;
path: string;
content: string;
created_at: string;
updated_at: string;
}
export interface CreateSkillRequest {
name: string;
description?: string;
content?: string;
config?: Record<string, unknown>;
files?: { path: string; content: string }[];
}
export interface UpdateSkillRequest {
name?: string;
description?: string;
content?: string;
config?: Record<string, unknown>;
files?: { path: string; content: string }[];
}
export interface SetAgentSkillsRequest {
skill_ids: string[];
}
export type RuntimePingStatus = "pending" | "running" | "completed" | "failed" | "timeout";
export interface RuntimePing {
id: string;
runtime_id: string;
status: RuntimePingStatus;
output?: string;
error?: string;
duration_ms?: number;
created_at: string;
updated_at: string;
}
export interface RuntimeUsage {
runtime_id: string;
date: string;
provider: string;
model: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
}

View file

@ -0,0 +1,82 @@
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
import type { MemberRole } from "./workspace";
// Issue API
export interface CreateIssueRequest {
title: string;
description?: string;
status?: IssueStatus;
priority?: IssuePriority;
assignee_type?: IssueAssigneeType;
assignee_id?: string;
parent_issue_id?: string;
acceptance_criteria?: string[];
context_refs?: string[];
due_date?: string;
}
export interface UpdateIssueRequest {
title?: string;
description?: string;
status?: IssueStatus;
priority?: IssuePriority;
assignee_type?: IssueAssigneeType | null;
assignee_id?: string | null;
position?: number;
due_date?: string | null;
acceptance_criteria?: string[];
context_refs?: string[];
}
export interface ListIssuesParams {
limit?: number;
offset?: number;
workspace_id?: string;
status?: IssueStatus;
priority?: IssuePriority;
assignee_id?: string;
}
export interface ListIssuesResponse {
issues: Issue[];
total: number;
}
export interface UpdateMeRequest {
name?: string;
avatar_url?: string;
}
export interface CreateMemberRequest {
email: string;
role?: MemberRole;
}
export interface UpdateMemberRequest {
role: MemberRole;
}
// Personal Access Tokens
export interface PersonalAccessToken {
id: string;
name: string;
token_prefix: string;
expires_at: string | null;
last_used_at: string | null;
created_at: string;
}
export interface CreatePersonalAccessTokenRequest {
name: string;
expires_in_days?: number;
}
export interface CreatePersonalAccessTokenResponse extends PersonalAccessToken {
token: string;
}
// Pagination
export interface PaginationParams {
limit?: number;
offset?: number;
}

View file

@ -0,0 +1,14 @@
export type CommentType = "comment" | "status_change" | "progress_update" | "system";
export type CommentAuthorType = "member" | "agent";
export interface Comment {
id: string;
issue_id: string;
author_type: CommentAuthorType;
author_id: string;
content: string;
type: CommentType;
created_at: string;
updated_at: string;
}

View file

@ -0,0 +1,22 @@
export type DaemonPairingSessionStatus = "pending" | "approved" | "claimed" | "expired";
export interface DaemonPairingSession {
token: string;
daemon_id: string;
device_name: string;
runtime_name: string;
runtime_type: string;
runtime_version: string;
workspace_id: string | null;
status: DaemonPairingSessionStatus;
approved_at: string | null;
claimed_at: string | null;
expires_at: string;
created_at: string;
updated_at: string;
link_url?: string | null;
}
export interface ApproveDaemonPairingSessionRequest {
workspace_id: string;
}

View file

@ -0,0 +1,126 @@
import type { Issue } from "./issue";
import type { Agent } from "./agent";
import type { InboxItem } from "./inbox";
import type { Comment } from "./comment";
import type { Workspace, MemberWithUser } from "./workspace";
// WebSocket event types (matching Go server protocol/events.go)
export type WSEventType =
| "issue:created"
| "issue:updated"
| "issue:deleted"
| "comment:created"
| "comment:updated"
| "comment:deleted"
| "agent:status"
| "agent:created"
| "agent:deleted"
| "task:dispatch"
| "task:progress"
| "task:completed"
| "task:failed"
| "inbox:new"
| "inbox:read"
| "inbox:archived"
| "inbox:batch-read"
| "inbox:batch-archived"
| "workspace:updated"
| "workspace:deleted"
| "member:added"
| "member:updated"
| "member:removed"
| "daemon:heartbeat"
| "daemon:register"
| "skill:created"
| "skill:updated"
| "skill:deleted";
export interface WSMessage<T = unknown> {
type: WSEventType;
payload: T;
}
export interface IssueCreatedPayload {
issue: Issue;
}
export interface IssueUpdatedPayload {
issue: Issue;
}
export interface IssueDeletedPayload {
issue_id: string;
}
export interface AgentStatusPayload {
agent: Agent;
}
export interface AgentCreatedPayload {
agent: Agent;
}
export interface AgentDeletedPayload {
agent_id: string;
workspace_id: string;
}
export interface InboxNewPayload {
item: InboxItem;
}
export interface InboxReadPayload {
item_id: string;
recipient_id: string;
}
export interface InboxArchivedPayload {
item_id: string;
recipient_id: string;
}
export interface InboxBatchReadPayload {
recipient_id: string;
count: number;
}
export interface InboxBatchArchivedPayload {
recipient_id: string;
count: number;
}
export interface CommentCreatedPayload {
comment: Comment;
}
export interface CommentUpdatedPayload {
comment: Comment;
}
export interface CommentDeletedPayload {
comment_id: string;
issue_id: string;
}
export interface WorkspaceUpdatedPayload {
workspace: Workspace;
}
export interface WorkspaceDeletedPayload {
workspace_id: string;
}
export interface MemberUpdatedPayload {
member: MemberWithUser;
}
export interface MemberAddedPayload {
member: MemberWithUser;
workspace_id: string;
}
export interface MemberRemovedPayload {
member_id: string;
user_id: string;
workspace_id: string;
}

View file

@ -0,0 +1,29 @@
import type { IssueStatus } from "./issue";
export type InboxSeverity = "action_required" | "attention" | "info";
export type InboxItemType =
| "issue_assigned"
| "review_requested"
| "agent_blocked"
| "agent_completed"
| "mentioned"
| "status_change";
export interface InboxItem {
id: string;
workspace_id: string;
recipient_type: "member" | "agent";
recipient_id: string;
actor_type: "member" | "agent" | null;
actor_id: string | null;
type: InboxItemType;
severity: InboxSeverity;
issue_id: string | null;
title: string;
body: string | null;
issue_status: IssueStatus | null;
read: boolean;
archived: boolean;
created_at: string;
}

View file

@ -0,0 +1,29 @@
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
export type {
Agent,
AgentStatus,
AgentRuntimeMode,
AgentVisibility,
AgentTriggerType,
AgentTool,
AgentTrigger,
AgentTask,
AgentRuntime,
RuntimeDevice,
CreateAgentRequest,
UpdateAgentRequest,
Skill,
SkillFile,
CreateSkillRequest,
UpdateSkillRequest,
SetAgentSkillsRequest,
RuntimeUsage,
RuntimePing,
RuntimePingStatus,
} from "./agent";
export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { Comment, CommentType, CommentAuthorType } from "./comment";
export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon";
export type * from "./events";
export type * from "./api";

View file

@ -0,0 +1,32 @@
export type IssueStatus =
| "backlog"
| "todo"
| "in_progress"
| "in_review"
| "done"
| "blocked"
| "cancelled";
export type IssuePriority = "urgent" | "high" | "medium" | "low" | "none";
export type IssueAssigneeType = "member" | "agent";
export interface Issue {
id: string;
workspace_id: string;
title: string;
description: string | null;
status: IssueStatus;
priority: IssuePriority;
assignee_type: IssueAssigneeType | null;
assignee_id: string | null;
creator_type: IssueAssigneeType;
creator_id: string;
parent_issue_id: string | null;
acceptance_criteria: string[];
context_refs: string[];
position: number;
due_date: string | null;
created_at: string;
updated_at: string;
}

View file

@ -0,0 +1,40 @@
export type MemberRole = "owner" | "admin" | "member";
export interface Workspace {
id: string;
name: string;
slug: string;
description: string | null;
context: string | null;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface Member {
id: string;
workspace_id: string;
user_id: string;
role: MemberRole;
created_at: string;
}
export interface User {
id: string;
name: string;
email: string;
avatar_url: string | null;
created_at: string;
updated_at: string;
}
export interface MemberWithUser {
id: string;
workspace_id: string;
user_id: string;
role: MemberRole;
created_at: string;
name: string;
email: string;
avatar_url: string | null;
}

View file

@ -1,7 +1,7 @@
import React from "react";
import { vi } from "vitest";
import { render, type RenderOptions } from "@testing-library/react";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
import type { User, Workspace, MemberWithUser, Agent } from "@/shared/types";
// Mock user
export const mockUser: User = {

View file

@ -1,14 +1,49 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
},
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"noEmit": true,
"allowJs": true,
"incremental": true
},
"include": ["next-env.d.ts", "src", "app", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"src",
"app",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View file

@ -13,8 +13,6 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
"@multica/types": path.resolve(__dirname, "../../packages/types/src"),
"@multica/sdk": path.resolve(__dirname, "../../packages/sdk/src"),
},
},
});