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:
commit
6ee034c6e9
151 changed files with 3664 additions and 6579 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
145
apps/web/components/common/rich-text-editor.css
Normal file
145
apps/web/components/common/rich-text-editor.css
Normal 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);
|
||||
}
|
||||
150
apps/web/components/common/rich-text-editor.tsx
Normal file
150
apps/web/components/common/rich-text-editor.tsx
Normal 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 };
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { IssueStatus } from "@multica/types";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { IssuePriority } from "@multica/types";
|
||||
import type { IssuePriority } from "@/shared/types";
|
||||
|
||||
export const PRIORITY_ORDER: IssuePriority[] = [
|
||||
"urgent",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { IssueStatus } from "@multica/types";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
|
||||
export const STATUS_ORDER: IssueStatus[] = [
|
||||
"backlog",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [] }),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: [
|
||||
"@multica/sdk",
|
||||
"@multica/types",
|
||||
"@multica/utils",
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
424
apps/web/shared/api/client.ts
Normal file
424
apps/web/shared/api/client.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
});
|
||||
}
|
||||
110
apps/web/shared/api/ws-client.ts
Normal file
110
apps/web/shared/api/ws-client.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
168
apps/web/shared/types/agent.ts
Normal file
168
apps/web/shared/types/agent.ts
Normal 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;
|
||||
}
|
||||
82
apps/web/shared/types/api.ts
Normal file
82
apps/web/shared/types/api.ts
Normal 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;
|
||||
}
|
||||
14
apps/web/shared/types/comment.ts
Normal file
14
apps/web/shared/types/comment.ts
Normal 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;
|
||||
}
|
||||
22
apps/web/shared/types/daemon.ts
Normal file
22
apps/web/shared/types/daemon.ts
Normal 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;
|
||||
}
|
||||
126
apps/web/shared/types/events.ts
Normal file
126
apps/web/shared/types/events.ts
Normal 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;
|
||||
}
|
||||
29
apps/web/shared/types/inbox.ts
Normal file
29
apps/web/shared/types/inbox.ts
Normal 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;
|
||||
}
|
||||
29
apps/web/shared/types/index.ts
Normal file
29
apps/web/shared/types/index.ts
Normal 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";
|
||||
32
apps/web/shared/types/issue.ts
Normal file
32
apps/web/shared/types/issue.ts
Normal 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;
|
||||
}
|
||||
40
apps/web/shared/types/workspace.ts
Normal file
40
apps/web/shared/types/workspace.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue