Merge pull request #136 from multica-ai/NevilleQingNY/logging-and-ui-polish
feat: logging, UI polish, and issues page rewrite
This commit is contained in:
commit
a997bcfec0
82 changed files with 2742 additions and 1751 deletions
|
|
@ -4,6 +4,7 @@ import { Suspense, useState } from "react";
|
|||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useNavigationStore } from "@/features/navigation";
|
||||
import { api } from "@/shared/api";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -40,7 +41,8 @@ function LoginPageContent() {
|
|||
await login(email, name || undefined);
|
||||
const wsList = await api.listWorkspaces();
|
||||
await hydrateWorkspace(wsList);
|
||||
router.push(searchParams.get("next") || "/issues");
|
||||
const fallback = useNavigationStore.getState().lastPath;
|
||||
router.push(searchParams.get("next") || fallback);
|
||||
} catch (err) {
|
||||
setError("Login failed. Make sure the server is running.");
|
||||
setSubmitting(false);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { WorkspaceAvatar } from "@/features/workspace";
|
|||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
|
|
@ -43,11 +42,14 @@ import { useWorkspaceStore } from "@/features/workspace";
|
|||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
|
||||
const navItems = [
|
||||
const primaryNav = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
{ href: "/issues", label: "Issues", icon: ListTodo },
|
||||
];
|
||||
|
||||
const workspaceNav = [
|
||||
{ href: "/agents", label: "Agents", icon: Bot },
|
||||
{ href: "/skills", label: "Skills", icon: Sparkles },
|
||||
{ href: "/issues", label: "Issues", icon: ListTodo },
|
||||
{ href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen },
|
||||
];
|
||||
|
||||
|
|
@ -73,7 +75,7 @@ export function AppSidebar() {
|
|||
return (
|
||||
<Sidebar variant="inset">
|
||||
{/* Workspace Switcher */}
|
||||
<SidebarHeader>
|
||||
<SidebarHeader className="py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<SidebarMenu className="min-w-0 flex-1">
|
||||
<SidebarMenuItem>
|
||||
|
|
@ -180,10 +182,8 @@ export function AppSidebar() {
|
|||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
pathname.startsWith(item.href + "/");
|
||||
{primaryNav.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
|
|
@ -194,7 +194,7 @@ export function AppSidebar() {
|
|||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
{item.label === "Inbox" && unreadCount > 0 && (
|
||||
<span className="ml-auto rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-medium text-primary-foreground">
|
||||
<span className="ml-auto text-xs">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -205,28 +205,29 @@ export function AppSidebar() {
|
|||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
{/* User */}
|
||||
<SidebarFooter>
|
||||
{user && (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="sm">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[9px] font-medium">
|
||||
{user.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</div>
|
||||
<span className="truncate">{user.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarFooter>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{workspaceNav.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
render={<Link href={item.href} />}
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ function CreateAgentDialog({
|
|||
{selectedRuntime?.name ?? "No runtime available"}
|
||||
</span>
|
||||
{selectedRuntime?.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-[10px] font-medium text-info">
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -222,7 +222,7 @@ function CreateAgentDialog({
|
|||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{device.name}</span>
|
||||
{device.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-[10px] font-medium text-info">
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -812,7 +812,7 @@ function TriggersTab({
|
|||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||
trigger.enabled ? "left-[18px]" : "left-0.5"
|
||||
trigger.enabled ? "left-4.5" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
|
@ -1007,7 +1007,7 @@ function AgentDetail({
|
|||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<span className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{agent.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-3 w-3" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { IssueDetail, StatusIcon } from "@/features/issues/components";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
GitPullRequest,
|
||||
MessageSquare,
|
||||
ArrowRightLeft,
|
||||
MoreHorizontal,
|
||||
Inbox,
|
||||
CheckCheck,
|
||||
Archive,
|
||||
BookCheck,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -28,33 +35,28 @@ const severityOrder: Record<InboxSeverity, number> = {
|
|||
info: 2,
|
||||
};
|
||||
|
||||
const typeIcons: Record<InboxItemType, typeof AlertCircle> = {
|
||||
agent_blocked: AlertCircle,
|
||||
review_requested: GitPullRequest,
|
||||
issue_assigned: CircleDot,
|
||||
agent_completed: CheckCircle2,
|
||||
mentioned: MessageSquare,
|
||||
status_change: ArrowRightLeft,
|
||||
};
|
||||
|
||||
const severityColors: Record<InboxSeverity, string> = {
|
||||
action_required: "text-destructive",
|
||||
attention: "text-warning",
|
||||
info: "text-muted-foreground",
|
||||
const typeLabels: Record<InboxItemType, string> = {
|
||||
issue_assigned: "Assigned",
|
||||
review_requested: "Review requested",
|
||||
agent_blocked: "Agent blocked",
|
||||
agent_completed: "Agent completed",
|
||||
mentioned: "Mentioned",
|
||||
status_change: "Status changed",
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// InboxListItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function InboxListItem({
|
||||
|
|
@ -66,107 +68,47 @@ function InboxListItem({
|
|||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const Icon = typeIcons[item.type] ?? CircleDot;
|
||||
const colorClass = severityColors[item.severity];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors ${
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
} ${!item.read ? "font-medium" : ""}`}
|
||||
}`}
|
||||
>
|
||||
<Icon className={`mt-0.5 h-4 w-4 shrink-0 ${colorClass}`} />
|
||||
<ActorAvatar
|
||||
actorType={item.actor_type ?? item.recipient_type}
|
||||
actorId={item.actor_id ?? item.recipient_id}
|
||||
size={28}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm">{item.title}</span>
|
||||
<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" />
|
||||
)}
|
||||
{!item.read && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{typeLabels[item.type] ?? item.type}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{timeAgo(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{(item.type === "agent_blocked" || item.type === "review_requested") && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<Bot className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Agent action</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!item.read && (
|
||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxDetail({
|
||||
item,
|
||||
onMarkRead,
|
||||
onArchive,
|
||||
}: {
|
||||
item: InboxItem;
|
||||
onMarkRead: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
}) {
|
||||
const Icon = typeIcons[item.type] ?? CircleDot;
|
||||
const colorClass = severityColors[item.severity];
|
||||
|
||||
const severityLabel: Record<InboxSeverity, string> = {
|
||||
action_required: "Action required",
|
||||
attention: "Needs attention",
|
||||
info: "Info",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={`mt-1 h-5 w-5 shrink-0 ${colorClass}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-semibold truncate">{item.title}</h2>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className={colorClass}>{severityLabel[item.severity]}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(item.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!item.read && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => onMarkRead(item.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
Mark read
|
||||
</Button>
|
||||
)}
|
||||
{item.issue_id && (
|
||||
<Link
|
||||
href={`/issues/${item.issue_id}`}
|
||||
className="inline-flex h-7 shrink-0 items-center rounded-md border px-2.5 text-xs font-medium transition-colors hover:bg-accent"
|
||||
>
|
||||
View Issue
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => onArchive(item.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{item.body && (
|
||||
<div className="mt-6 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
|
||||
{item.body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -174,7 +116,6 @@ function InboxDetail({
|
|||
export default function InboxPage() {
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
|
||||
// Read from global store (populated by workspace hydrate + useRealtimeSync)
|
||||
const storeItems = useInboxStore((s) => s.items);
|
||||
const loading = useInboxStore((s) => s.loading);
|
||||
|
||||
|
|
@ -189,19 +130,19 @@ export default function InboxPage() {
|
|||
);
|
||||
}, [storeItems]);
|
||||
|
||||
// Auto-select first item when items change
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && !selectedId) {
|
||||
setSelectedId(items[0]!.id);
|
||||
}
|
||||
}, [items, selectedId]);
|
||||
const selected = items.find((i) => i.id === selectedId) ?? null;
|
||||
const unreadCount = items.filter((i) => !i.read).length;
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
try {
|
||||
await api.markInboxRead(id);
|
||||
useInboxStore.getState().markRead(id);
|
||||
} catch (err) {
|
||||
toast.error("Failed to mark as read");
|
||||
// Click-to-read: select + auto-mark-read
|
||||
const handleSelect = async (item: InboxItem) => {
|
||||
setSelectedId(item.id);
|
||||
if (!item.read) {
|
||||
try {
|
||||
await api.markInboxRead(item.id);
|
||||
useInboxStore.getState().markRead(item.id);
|
||||
} catch {
|
||||
// silent — selection still works even if mark-read fails
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -209,17 +150,55 @@ export default function InboxPage() {
|
|||
try {
|
||||
await api.archiveInbox(id);
|
||||
useInboxStore.getState().archive(id);
|
||||
// If archived item was selected, clear selection
|
||||
if (selectedId === id) {
|
||||
setSelectedId("");
|
||||
}
|
||||
} catch (err) {
|
||||
if (selectedId === id) setSelectedId("");
|
||||
} catch {
|
||||
toast.error("Failed to archive");
|
||||
}
|
||||
};
|
||||
|
||||
const selected = items.find((i) => i.id === selectedId) ?? null;
|
||||
const unreadCount = items.filter((i) => !i.read).length;
|
||||
// Batch operations
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
useInboxStore.getState().markAllRead();
|
||||
await api.markAllInboxRead();
|
||||
} catch {
|
||||
toast.error("Failed to mark all as read");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveAll = async () => {
|
||||
try {
|
||||
useInboxStore.getState().archiveAll();
|
||||
setSelectedId("");
|
||||
await api.archiveAllInbox();
|
||||
} catch {
|
||||
toast.error("Failed to archive all");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveAllRead = async () => {
|
||||
try {
|
||||
const readIds = items.filter((i) => i.read).map((i) => i.id);
|
||||
useInboxStore.getState().archiveAllRead();
|
||||
if (readIds.includes(selectedId)) setSelectedId("");
|
||||
await api.archiveAllReadInbox();
|
||||
} catch {
|
||||
toast.error("Failed to archive read items");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveCompleted = async () => {
|
||||
try {
|
||||
await api.archiveCompletedInbox();
|
||||
setSelectedId("");
|
||||
await useInboxStore.getState().fetch();
|
||||
} catch {
|
||||
toast.error("Failed to archive completed");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -230,8 +209,8 @@ export default function InboxPage() {
|
|||
</div>
|
||||
<div className="space-y-1 p-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 px-4 py-3">
|
||||
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||
<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" />
|
||||
|
|
@ -243,7 +222,6 @@ export default function InboxPage() {
|
|||
<div className="flex-1 p-6">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="mt-4 h-4 w-32" />
|
||||
<Skeleton className="mt-6 h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -253,17 +231,53 @@ export default function InboxPage() {
|
|||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left column — inbox list */}
|
||||
<div className="w-80 shrink-0 overflow-y-auto border-r">
|
||||
<div className="flex h-12 items-center border-b px-4">
|
||||
<h1 className="text-sm font-semibold">Inbox</h1>
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<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">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuItem onClick={handleMarkAllRead}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleArchiveAll}>
|
||||
<Archive className="h-4 w-4" />
|
||||
Archive all
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleArchiveAllRead}>
|
||||
<BookCheck className="h-4 w-4" />
|
||||
Archive all read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleArchiveCompleted}>
|
||||
<ListChecks className="h-4 w-4" />
|
||||
Archive completed
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<p>No notifications yet</p>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
|
|
@ -272,7 +286,7 @@ export default function InboxPage() {
|
|||
key={item.id}
|
||||
item={item}
|
||||
isSelected={item.id === selectedId}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
onClick={() => handleSelect(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -280,14 +294,45 @@ export default function InboxPage() {
|
|||
</div>
|
||||
|
||||
{/* Right column — detail */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selected ? (
|
||||
<InboxDetail item={selected} onMarkRead={handleMarkRead} onArchive={handleArchive} />
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{selected?.issue_id ? (
|
||||
<IssueDetail
|
||||
issueId={selected.issue_id}
|
||||
showBreadcrumb={false}
|
||||
onDelete={() => {
|
||||
handleArchive(selected.id);
|
||||
}}
|
||||
/>
|
||||
) : selected ? (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold">{selected.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
|
||||
</p>
|
||||
{selected.body && (
|
||||
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
|
||||
{selected.body}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleArchive(selected.id)}
|
||||
>
|
||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
{items.length === 0
|
||||
? "Your inbox is empty"
|
||||
: "Select an item to view details"}
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Inbox className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm">
|
||||
{items.length === 0
|
||||
? "Your inbox is empty"
|
||||
: "Select a notification to view details"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,312 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { use, useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ChevronRight,
|
||||
Link2,
|
||||
Pencil,
|
||||
Send,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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 { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function shortDate(date: string | null): string {
|
||||
if (!date) return "—";
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PropRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[32px] items-center gap-3 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors">
|
||||
<span className="w-20 shrink-0 text-[13px] text-muted-foreground">{label}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5 text-[13px]">
|
||||
{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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AcceptanceCriteriaEditor({
|
||||
criteria,
|
||||
onUpdate,
|
||||
}: {
|
||||
criteria: string[];
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
}) {
|
||||
const [newItem, setNewItem] = useState("");
|
||||
|
||||
const addItem = () => {
|
||||
if (!newItem.trim()) return;
|
||||
onUpdate({ acceptance_criteria: [...criteria, newItem.trim()] });
|
||||
setNewItem("");
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
onUpdate({ acceptance_criteria: criteria.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Acceptance Criteria</h3>
|
||||
{criteria.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{criteria.map((item, i) => (
|
||||
<div key={i} className="group flex items-start gap-2 text-sm">
|
||||
<span className="mt-0.5 text-muted-foreground">•</span>
|
||||
<span className="flex-1">{item}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeItem(i)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(criteria.length > 0 || adding) ? (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addItem(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
autoFocus={adding}
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
onBlur={() => { if (!newItem.trim()) setAdding(false); }}
|
||||
placeholder="Add criteria..."
|
||||
aria-label="Add acceptance criteria"
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground h-7 px-2 text-xs"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
+ Add acceptance criteria
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context Refs Editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ContextRefsEditor({
|
||||
refs,
|
||||
onUpdate,
|
||||
}: {
|
||||
refs: string[];
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
}) {
|
||||
const [newRef, setNewRef] = useState("");
|
||||
|
||||
const addRef = () => {
|
||||
if (!newRef.trim()) return;
|
||||
onUpdate({ context_refs: [...refs, newRef.trim()] });
|
||||
setNewRef("");
|
||||
};
|
||||
|
||||
const removeRef = (index: number) => {
|
||||
onUpdate({ context_refs: refs.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const isUrl = (s: string) => s.startsWith("http://") || s.startsWith("https://");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Context References</h3>
|
||||
{refs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{refs.map((ref, i) => (
|
||||
<div key={i} className="group flex items-center gap-2 text-sm">
|
||||
<Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{isUrl(ref) ? (
|
||||
<a href={ref} target="_blank" rel="noopener noreferrer" className="flex-1 text-info hover:underline truncate">
|
||||
{ref}
|
||||
</a>
|
||||
) : (
|
||||
<span className="flex-1 truncate">{ref}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeRef(i)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(refs.length > 0 || adding) ? (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addRef(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
autoFocus={adding}
|
||||
value={newRef}
|
||||
onChange={(e) => setNewRef(e.target.value)}
|
||||
onBlur={() => { if (!newRef.trim()) setAdding(false); }}
|
||||
placeholder="Add reference URL..."
|
||||
aria-label="Add context reference URL"
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground h-7 px-2 text-xs"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
+ Add context reference
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
import { use } from "react";
|
||||
import { IssueDetail } from "@/features/issues/components";
|
||||
|
||||
export default function IssueDetailPage({
|
||||
params,
|
||||
|
|
@ -314,437 +9,5 @@ export default function IssueDetailPage({
|
|||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
const [issue, setIssue] = useState<Issue | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
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("");
|
||||
|
||||
// Watch the global issue store for real-time updates from other users/agents
|
||||
const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id));
|
||||
|
||||
useEffect(() => {
|
||||
if (storeIssue) {
|
||||
setIssue(storeIssue);
|
||||
}
|
||||
}, [storeIssue]);
|
||||
|
||||
useEffect(() => {
|
||||
setIssue(null);
|
||||
setComments([]);
|
||||
setLoading(true);
|
||||
Promise.all([api.getIssue(id), api.listComments(id)])
|
||||
.then(([iss, cmts]) => {
|
||||
setIssue(iss);
|
||||
setComments(cmts);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!commentText.trim() || submitting || !user) return;
|
||||
const content = commentText.trim();
|
||||
const tempId = "temp-" + Date.now();
|
||||
const tempComment: Comment = {
|
||||
id: tempId,
|
||||
issue_id: id,
|
||||
author_type: "member",
|
||||
author_id: user.id,
|
||||
content,
|
||||
type: "comment",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setComments((prev) => [...prev, tempComment]);
|
||||
setCommentText("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const comment = await api.createComment(id, content);
|
||||
setComments((prev) => prev.map((c) => (c.id === tempId ? comment : c)));
|
||||
} catch {
|
||||
setComments((prev) => prev.filter((c) => c.id !== tempId));
|
||||
toast.error("Failed to send comment");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateField = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
if (!issue) return;
|
||||
const prev = issue;
|
||||
setIssue((curr) => (curr ? ({ ...curr, ...updates } as Issue) : curr));
|
||||
api.updateIssue(id, updates).catch(() => {
|
||||
setIssue(prev);
|
||||
toast.error("Failed to update issue");
|
||||
});
|
||||
},
|
||||
[issue, id],
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteIssue(issue!.id);
|
||||
toast.success("Issue deleted");
|
||||
router.push("/issues");
|
||||
} catch {
|
||||
toast.error("Failed to delete issue");
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditComment = (c: Comment) => {
|
||||
setEditingCommentId(c.id);
|
||||
setEditContent(c.content);
|
||||
};
|
||||
|
||||
const handleSaveEditComment = async () => {
|
||||
if (!editingCommentId || !editContent.trim()) return;
|
||||
try {
|
||||
const updated = await api.updateComment(editingCommentId, editContent.trim());
|
||||
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
setEditingCommentId(null);
|
||||
} catch {
|
||||
toast.error("Failed to update comment");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId: string) => {
|
||||
try {
|
||||
await api.deleteComment(commentId);
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||
} catch {
|
||||
toast.error("Failed to delete comment");
|
||||
}
|
||||
};
|
||||
|
||||
// Real-time comment updates
|
||||
useWSEvent(
|
||||
"comment:created",
|
||||
useCallback((payload: unknown) => {
|
||||
const { comment } = payload as CommentCreatedPayload;
|
||||
if (comment.issue_id !== id) return;
|
||||
// Skip own comments — already added locally via API response
|
||||
if (comment.author_type === "member" && comment.author_id === user?.id) return;
|
||||
setComments((prev) => {
|
||||
if (prev.some((c) => c.id === comment.id)) return prev;
|
||||
return [...prev, comment];
|
||||
});
|
||||
}, [id, user?.id]),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"comment:updated",
|
||||
useCallback((payload: unknown) => {
|
||||
const { comment } = payload as CommentUpdatedPayload;
|
||||
if (comment.issue_id === id) {
|
||||
setComments((prev) => prev.map((c) => (c.id === comment.id ? comment : c)));
|
||||
}
|
||||
}, [id]),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"comment:deleted",
|
||||
useCallback((payload: unknown) => {
|
||||
const { comment_id, issue_id } = payload as CommentDeletedPayload;
|
||||
if (issue_id === id) {
|
||||
setComments((prev) => prev.filter((c) => c.id !== comment_id));
|
||||
}
|
||||
}, [id]),
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Issue not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* LEFT: Content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header bar */}
|
||||
<div className="sticky top-0 z-10 flex h-11 items-center justify-between border-b bg-background px-6 text-[13px]">
|
||||
<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>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete issue</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this issue and all its comments. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto w-full max-w-3xl px-8 py-8">
|
||||
<div className="mb-1 text-[13px] text-muted-foreground">{issue.id.slice(0, 8)}</div>
|
||||
|
||||
{editingTitle ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() });
|
||||
setEditingTitle(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() });
|
||||
setEditingTitle(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
className="text-xl font-semibold leading-snug tracking-tight"
|
||||
/>
|
||||
) : (
|
||||
<h1
|
||||
className="text-xl font-semibold leading-snug tracking-tight cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
|
||||
onClick={() => { setTitleDraft(issue.title); setEditingTitle(true); }}
|
||||
>
|
||||
{issue.title}
|
||||
</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-[14px] leading-[1.7] resize-none"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="mt-5 text-[14px] leading-[1.7] 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>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<AcceptanceCriteriaEditor
|
||||
criteria={issue.acceptance_criteria}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
<ContextRefsEditor
|
||||
refs={issue.context_refs}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="my-8 border-t" />
|
||||
|
||||
{/* Activity / Comments */}
|
||||
<div>
|
||||
<h2 className="text-[13px] font-medium">Activity</h2>
|
||||
|
||||
<div className="mt-4">
|
||||
{comments.map((comment) => {
|
||||
const isOwn = comment.author_type === "member" && comment.author_id === user?.id;
|
||||
return (
|
||||
<div key={comment.id} className={`group relative py-3${comment.id.startsWith("temp-") ? " opacity-60" : ""}`}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ActorAvatar
|
||||
actorType={comment.author_type}
|
||||
actorId={comment.author_id}
|
||||
size={28}
|
||||
getName={getActorName}
|
||||
getInitials={getActorInitials}
|
||||
/>
|
||||
<span className="text-[13px] font-medium">
|
||||
{getActorName(comment.author_type, comment.author_id)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="text-[12px] text-muted-foreground cursor-default">
|
||||
{timeAgo(comment.created_at)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
{new Date(comment.created_at).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isOwn && (
|
||||
<div className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => startEditComment(comment)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editingCommentId === comment.id ? (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveEditComment(); }} className="mt-2 pl-[38px]">
|
||||
<input
|
||||
autoFocus
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
aria-label="Edit comment"
|
||||
className="w-full text-[13px] bg-transparent border-b outline-none"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<div className="mt-2 pl-[38px] text-[13px] leading-[1.6] text-foreground/85 whitespace-pre-wrap">
|
||||
{comment.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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)}
|
||||
placeholder="Leave a comment..."
|
||||
className="flex-1 text-[13px]"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!commentText.trim() || submitting}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Properties sidebar */}
|
||||
<div className="w-60 shrink-0 overflow-y-auto border-l">
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-[12px] font-medium text-muted-foreground">
|
||||
Properties
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<PropRow label="Status">
|
||||
<StatusPicker status={issue.status} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Priority">
|
||||
<PriorityPicker priority={issue.priority} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Assignee">
|
||||
<AssigneePicker
|
||||
assigneeType={issue.assignee_type}
|
||||
assigneeId={issue.assignee_id}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Due date">
|
||||
<DueDatePicker dueDate={issue.due_date} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Created by">
|
||||
<ActorAvatar
|
||||
actorType={issue.creator_type}
|
||||
actorId={issue.creator_id}
|
||||
size={18}
|
||||
getName={getActorName}
|
||||
getInitials={getActorInitials}
|
||||
/>
|
||||
<span>{getActorName(issue.creator_type, issue.creator_id)}</span>
|
||||
</PropRow>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t pt-3 space-y-0.5">
|
||||
<PropRow label="Created">
|
||||
<span className="text-muted-foreground">{shortDate(issue.created_at)}</span>
|
||||
</PropRow>
|
||||
<PropRow label="Updated">
|
||||
<span className="text-muted-foreground">{shortDate(issue.updated_at)}</span>
|
||||
</PropRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <IssueDetail issueId={id} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Issue } from "@multica/types";
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ vi.mock("@/features/workspace", () => ({
|
|||
},
|
||||
{ getState: () => ({ workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] }) },
|
||||
),
|
||||
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
|
||||
}));
|
||||
|
||||
// Mock WebSocket context
|
||||
|
|
@ -57,18 +58,16 @@ vi.mock("sonner", () => ({
|
|||
}));
|
||||
|
||||
// Mock api
|
||||
const mockCreateIssue = vi.fn();
|
||||
const mockUpdateIssue = vi.fn();
|
||||
|
||||
vi.mock("@/shared/api", () => ({
|
||||
api: {
|
||||
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
|
||||
createIssue: (...args: any[]) => mockCreateIssue(...args),
|
||||
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the issue store — control state directly
|
||||
// Mock the issue store
|
||||
let mockStoreState: {
|
||||
issues: Issue[];
|
||||
loading: boolean;
|
||||
|
|
@ -79,32 +78,66 @@ let mockStoreState: {
|
|||
removeIssue: (id: string) => void;
|
||||
};
|
||||
|
||||
vi.mock("@/features/issues/store", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/issues", () => ({
|
||||
useIssueStore: (selector?: any) => {
|
||||
return selector ? selector(mockStoreState) : mockStoreState;
|
||||
},
|
||||
useIssueStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
StatusIcon: () => null,
|
||||
PriorityIcon: () => null,
|
||||
StatusPicker: ({ value, onChange }: any) => (
|
||||
<button onClick={() => onChange?.("todo")}>{value || "todo"}</button>
|
||||
),
|
||||
PriorityPicker: ({ value, onChange }: any) => (
|
||||
<button onClick={() => onChange?.("none")}>{value || "none"}</button>
|
||||
),
|
||||
statusConfig: {
|
||||
backlog: { label: "Backlog" },
|
||||
todo: { label: "Todo" },
|
||||
in_progress: { label: "In Progress" },
|
||||
in_review: { label: "In Review" },
|
||||
done: { label: "Done" },
|
||||
blocked: { label: "Blocked" },
|
||||
cancelled: { label: "Cancelled" },
|
||||
}));
|
||||
|
||||
// Mock view store
|
||||
const mockViewState = {
|
||||
viewMode: "board" as const,
|
||||
statusFilters: [] as string[],
|
||||
priorityFilters: [] as string[],
|
||||
setViewMode: vi.fn(),
|
||||
toggleStatusFilter: vi.fn(),
|
||||
togglePriorityFilter: vi.fn(),
|
||||
clearFilters: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/features/issues/stores/view-store", () => ({
|
||||
useIssueViewStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
|
||||
{ getState: () => mockViewState, setState: vi.fn() },
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock issue config
|
||||
vi.mock("@/features/issues/config", () => ({
|
||||
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
|
||||
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
|
||||
STATUS_CONFIG: {
|
||||
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
|
||||
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
|
||||
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
|
||||
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
|
||||
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
},
|
||||
priorityConfig: {
|
||||
urgent: { label: "Urgent" },
|
||||
high: { label: "High" },
|
||||
medium: { label: "Medium" },
|
||||
low: { label: "Low" },
|
||||
none: { label: "None" },
|
||||
PRIORITY_ORDER: ["urgent", "high", "medium", "low", "none"],
|
||||
PRIORITY_CONFIG: {
|
||||
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
|
||||
high: { label: "High", bars: 3, color: "text-warning" },
|
||||
medium: { label: "Medium", bars: 2, color: "text-warning" },
|
||||
low: { label: "Low", bars: 1, color: "text-info" },
|
||||
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -116,6 +149,33 @@ vi.mock("@/features/modals", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
// Mock dnd-kit
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children }: any) => children,
|
||||
DragOverlay: () => null,
|
||||
PointerSensor: class {},
|
||||
useSensor: () => ({}),
|
||||
useSensors: () => [],
|
||||
useDroppable: () => ({ setNodeRef: vi.fn(), isOver: false }),
|
||||
pointerWithin: vi.fn(),
|
||||
closestCenter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
useSortable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
transform: null,
|
||||
transition: null,
|
||||
isDragging: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/utilities", () => ({
|
||||
CSS: { Transform: { toString: () => undefined } },
|
||||
}));
|
||||
|
||||
const issueDefaults = {
|
||||
parent_issue_id: null,
|
||||
acceptance_criteria: [],
|
||||
|
|
@ -188,13 +248,15 @@ describe("IssuesPage", () => {
|
|||
updateIssue: vi.fn(),
|
||||
removeIssue: vi.fn(),
|
||||
};
|
||||
mockViewState.viewMode = "board";
|
||||
mockViewState.statusFilters = [];
|
||||
mockViewState.priorityFilters = [];
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
mockStoreState.loading = true;
|
||||
mockStoreState.issues = [];
|
||||
render(<IssuesPage />);
|
||||
// Now shows skeleton instead of text
|
||||
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -222,66 +284,40 @@ describe("IssuesPage", () => {
|
|||
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("switches to list view", async () => {
|
||||
it("shows workspace breadcrumb", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'New Issue' button", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows filter buttons", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Implement auth")).toBeInTheDocument();
|
||||
|
||||
const listButton = screen.getByText("List");
|
||||
await user.click(listButton);
|
||||
|
||||
expect(screen.getByText("Implement auth")).toBeInTheDocument();
|
||||
expect(screen.getByText("Design landing page")).toBeInTheDocument();
|
||||
expect(screen.getByText("Status: All")).toBeInTheDocument();
|
||||
expect(screen.getByText("Priority: All")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'New Issue' button", async () => {
|
||||
it("shows empty state when no issues match", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows create dialog when New Issue is clicked", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
||||
await user.click(screen.getByText("New Issue"));
|
||||
|
||||
// Create dialog is now a global modal, just check the button was clicked
|
||||
// The modal renders in ModalRegistry which is outside IssuesPage
|
||||
});
|
||||
|
||||
it("creates an issue via the dialog", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
||||
await user.click(screen.getByText("New Issue"));
|
||||
|
||||
// Create dialog is now a global modal in ModalRegistry
|
||||
// This test verifies the page itself doesn't crash
|
||||
});
|
||||
|
||||
it("handles API error gracefully", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
// Should render without crashing even with empty issues
|
||||
expect(screen.queryAllByRole("generic").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("No matching issues")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,471 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Columns3,
|
||||
List,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
useDroppable,
|
||||
closestCorners,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Issue, IssueStatus, IssuePriority } from "@multica/types";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER, STATUS_ORDER } from "@/features/issues/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectGroup,
|
||||
} from "@/components/ui/select";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { api } from "@/shared/api";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { IssuesPage } from "@/features/issues/components/issues-page";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
const BOARD_STATUSES: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board View — Card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BoardCardContent({ issue }: { issue: Issue }) {
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span>{issue.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[13px] leading-snug">{issue.title}</p>
|
||||
<div className="mt-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{issue.assignee_type && issue.assignee_id && (
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={20}
|
||||
getName={getActorName}
|
||||
getInitials={getActorInitials}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{issue.due_date && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(issue.due_date)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draggable card wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DraggableBoardCard({ issue }: { issue: Issue }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: issue.id,
|
||||
data: { status: issue.status },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={isDragging ? "opacity-30" : ""}
|
||||
>
|
||||
<Link
|
||||
href={`/issues/${issue.id}`}
|
||||
className={`block transition-colors hover:opacity-80 ${isDragging ? "pointer-events-none" : ""}`}
|
||||
>
|
||||
<BoardCardContent issue={issue} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Droppable column
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DroppableColumn({
|
||||
status,
|
||||
issues,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issues: Issue[];
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
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>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[200px] flex-1 space-y-1.5 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver ? "bg-accent/40" : ""
|
||||
}`}
|
||||
>
|
||||
{issues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
{issues.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">No issues</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board View (with DnD)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BoardView({
|
||||
issues,
|
||||
onMoveIssue,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
onMoveIssue: (issueId: string, newStatus: IssueStatus) => void;
|
||||
}) {
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
})
|
||||
);
|
||||
|
||||
const visibleStatuses = BOARD_STATUSES;
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const issue = issues.find((i) => i.id === event.active.id);
|
||||
if (issue) setActiveIssue(issue);
|
||||
},
|
||||
[issues]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveIssue(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const issueId = active.id as string;
|
||||
let targetStatus: IssueStatus | undefined;
|
||||
|
||||
if (visibleStatuses.includes(over.id as IssueStatus)) {
|
||||
targetStatus = over.id as IssueStatus;
|
||||
} else {
|
||||
const targetIssue = issues.find((i) => i.id === over.id);
|
||||
if (targetIssue) targetStatus = targetIssue.status;
|
||||
}
|
||||
|
||||
if (targetStatus) {
|
||||
const currentIssue = issues.find((i) => i.id === issueId);
|
||||
if (currentIssue && currentIssue.status !== targetStatus) {
|
||||
onMoveIssue(issueId, targetStatus);
|
||||
}
|
||||
}
|
||||
},
|
||||
[issues, onMoveIssue, visibleStatuses]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 gap-3 overflow-x-auto p-4">
|
||||
{visibleStatuses.map((status) => (
|
||||
<DroppableColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issues={issues.filter((i) => i.status === status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeIssue ? (
|
||||
<div className="w-64 rotate-2 opacity-90 shadow-lg">
|
||||
<BoardCardContent issue={activeIssue} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ListRow({ issue }: { issue: Issue }) {
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issue.id}`}
|
||||
className="flex h-9 items-center gap-2 px-4 text-[13px] transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">
|
||||
{issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
|
||||
{issue.due_date && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDate(issue.due_date)}
|
||||
</span>
|
||||
)}
|
||||
{issue.assignee_type && issue.assignee_id && (
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={20}
|
||||
getName={getActorName}
|
||||
getInitials={getActorInitials}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ListView({ issues }: { issues: Issue[] }) {
|
||||
const groupOrder = STATUS_ORDER.filter((s) => s !== "cancelled");
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
<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">{filtered.length}</span>
|
||||
</div>
|
||||
{filtered.map((issue) => (
|
||||
<ListRow key={issue.id} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create Issue Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ViewMode = "board" | "list";
|
||||
|
||||
export default function IssuesPage() {
|
||||
const [view, setView] = useState<ViewMode>("board");
|
||||
const [filterStatus, setFilterStatus] = useState<IssueStatus | "">("");
|
||||
const [filterPriority, setFilterPriority] = useState<IssuePriority | "">("");
|
||||
|
||||
// Read from global store (populated by workspace hydrate + useRealtimeSync)
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
|
||||
// Apply local filters
|
||||
const issues = useMemo(() => {
|
||||
return allIssues.filter((issue) => {
|
||||
if (filterStatus && issue.status !== filterStatus) return false;
|
||||
if (filterPriority && issue.priority !== filterPriority) return false;
|
||||
return true;
|
||||
});
|
||||
}, [allIssues, filterStatus, filterPriority]);
|
||||
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus) => {
|
||||
// Optimistic update in store
|
||||
useIssueStore.getState().updateIssue(issueId, { status: newStatus });
|
||||
|
||||
// Persist to API
|
||||
api.updateIssue(issueId, { status: newStatus }).catch((err) => {
|
||||
toast.error("Failed to move issue");
|
||||
// Revert on error by refetching
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
<div className="flex flex-1 gap-3 overflow-x-auto p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-semibold">All Issues</h1>
|
||||
<div className="ml-2 flex items-center rounded-md border p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setView("board")}
|
||||
className={
|
||||
view === "board"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}
|
||||
>
|
||||
<Columns3 className="h-3 w-3" />
|
||||
Board
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setView("list")}
|
||||
className={
|
||||
view === "list"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}
|
||||
>
|
||||
<List className="h-3 w-3" />
|
||||
List
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={filterStatus || undefined} onValueChange={(v) => setFilterStatus((v ?? "") as IssueStatus | "")}>
|
||||
<SelectTrigger size="sm" className="text-xs">
|
||||
<SelectValue placeholder="All Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="">All Status</SelectItem>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>{STATUS_CONFIG[s].label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterPriority || undefined} onValueChange={(v) => setFilterPriority((v ?? "") as IssuePriority | "")}>
|
||||
<SelectTrigger size="sm" className="text-xs">
|
||||
<SelectValue placeholder="All Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="">All Priority</SelectItem>
|
||||
{PRIORITY_ORDER.map((p) => (
|
||||
<SelectItem key={p} value={p}>{PRIORITY_CONFIG[p].label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => useModalStore.getState().open("create-issue")}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Issue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{issues.length === 0 && !loading ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<p>No matching issues</p>
|
||||
{(filterStatus || filterPriority) && (
|
||||
<button
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => { setFilterStatus(""); setFilterPriority(""); }}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : view === "board" ? (
|
||||
<BoardView issues={issues} onMoveIssue={handleMoveIssue} />
|
||||
) : (
|
||||
<ListView issues={issues} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function Page() {
|
||||
return <IssuesPage />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
|
|||
elements.push(
|
||||
<pre
|
||||
key={`code-${i}`}
|
||||
className="my-3 overflow-x-auto rounded-md bg-muted px-4 py-3 text-[13px] leading-relaxed"
|
||||
className="my-3 overflow-x-auto rounded-md bg-muted px-4 py-3 text-sm leading-relaxed"
|
||||
>
|
||||
<code>{codeLines.join("\n")}</code>
|
||||
</pre>
|
||||
|
|
@ -79,7 +79,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
|
|||
const body = dataRows.slice(1).map(parseRow);
|
||||
elements.push(
|
||||
<div key={`table-${i}`} className="my-3 overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{header.map((h, hi) => (
|
||||
|
|
@ -110,7 +110,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
|
|||
// Heading
|
||||
if (line.startsWith("## ")) {
|
||||
elements.push(
|
||||
<h2 key={`h2-${i}`} className="mt-6 mb-2 text-[15px] font-semibold">
|
||||
<h2 key={`h2-${i}`} className="mt-6 mb-2 text-base font-semibold">
|
||||
{line.slice(3)}
|
||||
</h2>,
|
||||
);
|
||||
|
|
@ -119,7 +119,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
|
|||
}
|
||||
if (line.startsWith("### ")) {
|
||||
elements.push(
|
||||
<h3 key={`h3-${i}`} className="mt-4 mb-1.5 text-[14px] font-medium">
|
||||
<h3 key={`h3-${i}`} className="mt-4 mb-1.5 text-sm font-medium">
|
||||
{line.slice(4)}
|
||||
</h3>,
|
||||
);
|
||||
|
|
@ -132,7 +132,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
|
|||
const checked = line.includes("[x]");
|
||||
const text = line.replace(/^- \[[ x]\] /, "");
|
||||
elements.push(
|
||||
<div key={`check-${i}`} className="flex items-center gap-2 py-0.5 text-[13px] text-foreground/80">
|
||||
<div key={`check-${i}`} className="flex items-center gap-2 py-0.5 text-sm text-foreground/80">
|
||||
<input type="checkbox" checked={checked} readOnly className="h-3.5 w-3.5 rounded" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
|
|
@ -142,8 +142,8 @@ function renderMarkdown(text: string): React.ReactNode[] {
|
|||
}
|
||||
if (line.startsWith("- ")) {
|
||||
elements.push(
|
||||
<div key={`li-${i}`} className="flex gap-2 py-0.5 text-[13px] text-foreground/80">
|
||||
<span className="mt-[7px] h-1 w-1 shrink-0 rounded-full bg-foreground/40" />
|
||||
<div key={`li-${i}`} className="flex gap-2 py-0.5 text-sm text-foreground/80">
|
||||
<span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-foreground/40" />
|
||||
<span>{renderInline(line.slice(2))}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -155,7 +155,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
|
|||
const num = line.match(/^(\d+)\. /)![1]!;
|
||||
const text = line.replace(/^\d+\. /, "");
|
||||
elements.push(
|
||||
<div key={`ol-${i}`} className="flex gap-2 py-0.5 text-[13px] text-foreground/80">
|
||||
<div key={`ol-${i}`} className="flex gap-2 py-0.5 text-sm text-foreground/80">
|
||||
<span className="w-4 shrink-0 text-right text-muted-foreground">{num}.</span>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
|
|
@ -173,7 +173,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
|
|||
|
||||
// Paragraph
|
||||
elements.push(
|
||||
<p key={`p-${i}`} className="text-[13px] leading-[1.7] text-foreground/85">
|
||||
<p key={`p-${i}`} className="text-sm leading-relaxed text-foreground/85">
|
||||
{renderInline(line)}
|
||||
</p>
|
||||
);
|
||||
|
|
@ -189,7 +189,7 @@ function renderInline(text: string): React.ReactNode {
|
|||
return parts.map((part, i) => {
|
||||
if (part.startsWith("`") && part.endsWith("`")) {
|
||||
return (
|
||||
<code key={i} className="rounded bg-muted px-1 py-0.5 text-[12px]">
|
||||
<code key={i} className="rounded bg-muted px-1 py-0.5 text-xs">
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
|
|
@ -220,8 +220,8 @@ function DocListItem({
|
|||
>
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium">{doc.title}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<div className="truncate text-sm font-medium">{doc.title}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{doc.createdBy}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(doc.updatedAt)}</span>
|
||||
|
|
@ -239,7 +239,7 @@ function DocDetail({ doc }: { doc: KBDocument }) {
|
|||
<h1 className="text-xl font-semibold tracking-tight">{doc.title}</h1>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="mt-2 flex items-center gap-3 text-[12px] text-muted-foreground">
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>By {doc.createdBy}</span>
|
||||
<span>·</span>
|
||||
<span>Updated {timeAgo(doc.updatedAt)}</span>
|
||||
|
|
@ -251,7 +251,7 @@ function DocDetail({ doc }: { doc: KBDocument }) {
|
|||
{/* Referenced by */}
|
||||
{doc.referencedBy.length > 0 && (
|
||||
<div className="mt-10 border-t pt-4">
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Referenced by</span>
|
||||
</div>
|
||||
|
|
@ -259,7 +259,7 @@ function DocDetail({ doc }: { doc: KBDocument }) {
|
|||
{doc.referencedBy.map((ref) => (
|
||||
<span
|
||||
key={ref}
|
||||
className="rounded bg-muted px-2 py-0.5 text-[12px] font-mono"
|
||||
className="rounded bg-muted px-2 py-0.5 text-xs font-mono"
|
||||
>
|
||||
{ref}
|
||||
</span>
|
||||
|
|
@ -309,7 +309,7 @@ export default function KnowledgeBasePage() {
|
|||
placeholder="Search docs..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 flex-1 text-[13px]"
|
||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 flex-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -325,7 +325,7 @@ export default function KnowledgeBasePage() {
|
|||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No documents found
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { useNavigationStore } from "@/features/navigation";
|
||||
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
|
@ -14,6 +15,7 @@ export default function DashboardLayout({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
|
|
@ -24,6 +26,10 @@ export default function DashboardLayout({
|
|||
}
|
||||
}, [user, isLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
useNavigationStore.getState().onPathChange(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
|
|
@ -35,9 +41,9 @@ export default function DashboardLayout({
|
|||
if (!user || !workspace) return null;
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<SidebarProvider className="h-svh">
|
||||
<AppSidebar />
|
||||
<SidebarInset>{children}</SidebarInset>
|
||||
<SidebarInset className="overflow-hidden">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default function RootLayout({
|
|||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full overflow-hidden", geist.variable, geistMono.variable)}
|
||||
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<ThemeProvider>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useNavigationStore } from "@/features/navigation";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/issues");
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const lastPath = useNavigationStore.getState().lastPath;
|
||||
router.replace(lastPath);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { Bot } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
|
||||
interface ActorAvatarProps {
|
||||
actorType: string;
|
||||
|
|
@ -18,8 +21,12 @@ function ActorAvatar({
|
|||
getInitials,
|
||||
className,
|
||||
}: ActorAvatarProps) {
|
||||
const name = getName?.(actorType, actorId);
|
||||
const initials = getInitials?.(actorType, actorId);
|
||||
const actorNameHook = useActorName();
|
||||
const resolveName = getName ?? actorNameHook.getActorName;
|
||||
const resolveInitials = getInitials ?? actorNameHook.getActorInitials;
|
||||
|
||||
const name = resolveName(actorType, actorId);
|
||||
const initials = resolveInitials(actorType, actorId);
|
||||
const isAgent = actorType === "agent";
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ function createComponents(
|
|||
p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,
|
||||
// Styled lists
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-2 space-y-1 ps-[16px] pe-2 list-disc marker:text-muted-foreground">
|
||||
<ul className="my-2 space-y-1 ps-4 pe-2 list-disc marker:text-muted-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
|
|
@ -189,7 +189,7 @@ function createComponents(
|
|||
p: ({ children }) => <p className="my-3 leading-relaxed">{children}</p>,
|
||||
// Styled lists
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-3 space-y-1.5 ps-[16px] pe-2 list-disc marker:text-muted-foreground">
|
||||
<ul className="my-3 space-y-1.5 ps-4 pe-2 list-disc marker:text-muted-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { useEffect, type ReactNode } from "react";
|
|||
import { useAuthStore } from "./store";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
|
||||
/**
|
||||
* Initializes auth + workspace state from localStorage on mount.
|
||||
|
|
@ -25,7 +28,7 @@ export function AuthInitializer({ children }: { children: ReactNode }) {
|
|||
|
||||
api.listWorkspaces().then((wsList) => {
|
||||
hydrateWorkspace(wsList, wsId);
|
||||
}).catch(console.error);
|
||||
}).catch((err) => logger.error("workspace hydration failed", err));
|
||||
}, [user, isLoading, hydrateWorkspace]);
|
||||
|
||||
return <>{children}</>;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { InboxItem } from "@multica/types";
|
||||
import type { InboxItem, IssueStatus } from "@multica/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("inbox-store");
|
||||
|
||||
interface InboxState {
|
||||
items: InboxItem[];
|
||||
|
|
@ -12,6 +15,10 @@ interface InboxState {
|
|||
addItem: (item: InboxItem) => void;
|
||||
markRead: (id: string) => void;
|
||||
archive: (id: string) => void;
|
||||
markAllRead: () => void;
|
||||
archiveAll: () => void;
|
||||
archiveAllRead: () => void;
|
||||
updateIssueStatus: (issueId: string, status: IssueStatus) => void;
|
||||
unreadCount: () => number;
|
||||
}
|
||||
|
||||
|
|
@ -20,14 +27,14 @@ export const useInboxStore = create<InboxState>((set, get) => ({
|
|||
loading: true,
|
||||
|
||||
fetch: async () => {
|
||||
console.log("[inbox-store] fetch start");
|
||||
logger.debug("fetch start");
|
||||
set({ loading: true });
|
||||
try {
|
||||
const data = await api.listInbox();
|
||||
console.log("[inbox-store] fetched", data.length, "items");
|
||||
logger.info("fetched", data.length, "items");
|
||||
set({ items: data, loading: false });
|
||||
} catch (err) {
|
||||
console.error("[inbox-store] fetch failed", err);
|
||||
logger.error("fetch failed", err);
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
|
@ -47,5 +54,25 @@ export const useInboxStore = create<InboxState>((set, get) => ({
|
|||
set((s) => ({
|
||||
items: s.items.map((i) => (i.id === id ? { ...i, archived: true } : i)),
|
||||
})),
|
||||
markAllRead: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)),
|
||||
})),
|
||||
archiveAll: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (!i.archived ? { ...i, archived: true } : i)),
|
||||
})),
|
||||
archiveAllRead: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.read && !i.archived ? { ...i, archived: true } : i
|
||||
),
|
||||
})),
|
||||
updateIssueStatus: (issueId, status) =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.issue_id === issueId ? { ...i, issue_status: status } : i
|
||||
),
|
||||
})),
|
||||
unreadCount: () => get().items.filter((i) => !i.read && !i.archived).length,
|
||||
}));
|
||||
|
|
|
|||
79
apps/web/features/issues/components/board-card.tsx
Normal file
79
apps/web/features/issues/components/board-card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Issue } from "@multica/types";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function BoardCardContent({ issue }: { issue: Issue }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span>{issue.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm leading-snug">{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 && (
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{issue.due_date && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(issue.due_date)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DraggableBoardCard({ issue }: { issue: Issue }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: issue.id,
|
||||
data: { status: issue.status },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={isDragging ? "opacity-30" : ""}
|
||||
>
|
||||
<Link
|
||||
href={`/issues/${issue.id}`}
|
||||
className={`block transition-colors hover:opacity-80 ${isDragging ? "pointer-events-none" : ""}`}
|
||||
>
|
||||
<BoardCardContent issue={issue} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/features/issues/components/board-column.tsx
Normal file
43
apps/web/features/issues/components/board-column.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import type { Issue, IssueStatus } from "@multica/types";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { DraggableBoardCard } from "./board-card";
|
||||
|
||||
export function BoardColumn({
|
||||
status,
|
||||
issues,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issues: Issue[];
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
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>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[200px] flex-1 space-y-1.5 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
{issues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
{issues.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
No issues
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/web/features/issues/components/board-view.tsx
Normal file
103
apps/web/features/issues/components/board-view.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
pointerWithin,
|
||||
closestCenter,
|
||||
type CollisionDetection,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import type { Issue, IssueStatus } from "@multica/types";
|
||||
import { BoardColumn } from "./board-column";
|
||||
import { BoardCardContent } from "./board-card";
|
||||
|
||||
const kanbanCollision: CollisionDetection = (args) => {
|
||||
const pointer = pointerWithin(args);
|
||||
if (pointer.length > 0) return pointer;
|
||||
return closestCenter(args);
|
||||
};
|
||||
|
||||
export function BoardView({
|
||||
issues,
|
||||
visibleStatuses,
|
||||
onMoveIssue,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
visibleStatuses: IssueStatus[];
|
||||
onMoveIssue: (issueId: string, newStatus: IssueStatus) => void;
|
||||
}) {
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const issue = issues.find((i) => i.id === event.active.id);
|
||||
if (issue) setActiveIssue(issue);
|
||||
},
|
||||
[issues]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveIssue(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const issueId = active.id as string;
|
||||
let targetStatus: IssueStatus | undefined;
|
||||
|
||||
if (visibleStatuses.includes(over.id as IssueStatus)) {
|
||||
targetStatus = over.id as IssueStatus;
|
||||
} else {
|
||||
const targetIssue = issues.find((i) => i.id === over.id);
|
||||
if (targetIssue) targetStatus = targetIssue.status;
|
||||
}
|
||||
|
||||
if (targetStatus) {
|
||||
const currentIssue = issues.find((i) => i.id === issueId);
|
||||
if (currentIssue && currentIssue.status !== targetStatus) {
|
||||
onMoveIssue(issueId, targetStatus);
|
||||
}
|
||||
}
|
||||
},
|
||||
[issues, onMoveIssue, visibleStatuses]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={kanbanCollision}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 gap-3 overflow-x-auto p-4">
|
||||
{visibleStatuses.map((status) => (
|
||||
<BoardColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issues={issues.filter((i) => i.status === status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeIssue ? (
|
||||
<div className="w-64 rotate-1 cursor-grabbing opacity-95 shadow-md">
|
||||
<BoardCardContent issue={activeIssue} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export { StatusIcon } from "./status-icon";
|
||||
export { PriorityIcon } from "./priority-icon";
|
||||
export { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers";
|
||||
export { IssueDetail } from "./issue-detail";
|
||||
export { IssuesPage } from "./issues-page";
|
||||
|
|
|
|||
755
apps/web/features/issues/components/issue-detail.tsx
Normal file
755
apps/web/features/issues/components/issue-detail.tsx
Normal file
|
|
@ -0,0 +1,755 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ChevronRight,
|
||||
Link2,
|
||||
Pencil,
|
||||
Send,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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 { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function shortDate(date: string | null): string {
|
||||
if (!date) return "—";
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PropRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
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">
|
||||
{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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AcceptanceCriteriaEditor({
|
||||
criteria,
|
||||
onUpdate,
|
||||
}: {
|
||||
criteria: string[];
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
}) {
|
||||
const [newItem, setNewItem] = useState("");
|
||||
|
||||
const addItem = () => {
|
||||
if (!newItem.trim()) return;
|
||||
onUpdate({ acceptance_criteria: [...criteria, newItem.trim()] });
|
||||
setNewItem("");
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
onUpdate({ acceptance_criteria: criteria.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Acceptance Criteria</h3>
|
||||
{criteria.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{criteria.map((item, i) => (
|
||||
<div key={i} className="group flex items-start gap-2 text-sm">
|
||||
<span className="mt-0.5 text-muted-foreground">•</span>
|
||||
<span className="flex-1">{item}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeItem(i)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(criteria.length > 0 || adding) ? (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addItem(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
autoFocus={adding}
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
onBlur={() => { if (!newItem.trim()) setAdding(false); }}
|
||||
placeholder="Add criteria..."
|
||||
aria-label="Add acceptance criteria"
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground h-7 px-2 text-xs"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
+ Add acceptance criteria
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context Refs Editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ContextRefsEditor({
|
||||
refs,
|
||||
onUpdate,
|
||||
}: {
|
||||
refs: string[];
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
}) {
|
||||
const [newRef, setNewRef] = useState("");
|
||||
|
||||
const addRef = () => {
|
||||
if (!newRef.trim()) return;
|
||||
onUpdate({ context_refs: [...refs, newRef.trim()] });
|
||||
setNewRef("");
|
||||
};
|
||||
|
||||
const removeRef = (index: number) => {
|
||||
onUpdate({ context_refs: refs.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const isUrl = (s: string) => s.startsWith("http://") || s.startsWith("https://");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Context References</h3>
|
||||
{refs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{refs.map((ref, i) => (
|
||||
<div key={i} className="group flex items-center gap-2 text-sm">
|
||||
<Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{isUrl(ref) ? (
|
||||
<a href={ref} target="_blank" rel="noopener noreferrer" className="flex-1 text-info hover:underline truncate">
|
||||
{ref}
|
||||
</a>
|
||||
) : (
|
||||
<span className="flex-1 truncate">{ref}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeRef(i)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(refs.length > 0 || adding) ? (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addRef(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
autoFocus={adding}
|
||||
value={newRef}
|
||||
onChange={(e) => setNewRef(e.target.value)}
|
||||
onBlur={() => { if (!newRef.trim()) setAdding(false); }}
|
||||
placeholder="Add reference URL..."
|
||||
aria-label="Add context reference URL"
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground h-7 px-2 text-xs"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
+ Add context reference
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface IssueDetailProps {
|
||||
issueId: string;
|
||||
showBreadcrumb?: boolean;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssueDetail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailProps) {
|
||||
const id = issueId;
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { getActorName } = useActorName();
|
||||
const [issue, setIssue] = useState<Issue | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
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("");
|
||||
|
||||
// Watch the global issue store for real-time updates from other users/agents
|
||||
const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id));
|
||||
|
||||
useEffect(() => {
|
||||
if (storeIssue) {
|
||||
setIssue(storeIssue);
|
||||
}
|
||||
}, [storeIssue]);
|
||||
|
||||
useEffect(() => {
|
||||
setIssue(null);
|
||||
setComments([]);
|
||||
setLoading(true);
|
||||
Promise.all([api.getIssue(id), api.listComments(id)])
|
||||
.then(([iss, cmts]) => {
|
||||
setIssue(iss);
|
||||
setComments(cmts);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!commentText.trim() || submitting || !user) return;
|
||||
const content = commentText.trim();
|
||||
const tempId = "temp-" + Date.now();
|
||||
const tempComment: Comment = {
|
||||
id: tempId,
|
||||
issue_id: id,
|
||||
author_type: "member",
|
||||
author_id: user.id,
|
||||
content,
|
||||
type: "comment",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setComments((prev) => [...prev, tempComment]);
|
||||
setCommentText("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const comment = await api.createComment(id, content);
|
||||
setComments((prev) => prev.map((c) => (c.id === tempId ? comment : c)));
|
||||
} catch {
|
||||
setComments((prev) => prev.filter((c) => c.id !== tempId));
|
||||
toast.error("Failed to send comment");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateField = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
if (!issue) return;
|
||||
const prev = issue;
|
||||
setIssue((curr) => (curr ? ({ ...curr, ...updates } as Issue) : curr));
|
||||
api.updateIssue(id, updates).catch(() => {
|
||||
setIssue(prev);
|
||||
toast.error("Failed to update issue");
|
||||
});
|
||||
},
|
||||
[issue, id],
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteIssue(issue!.id);
|
||||
toast.success("Issue deleted");
|
||||
if (onDelete) onDelete();
|
||||
else router.push("/issues");
|
||||
} catch {
|
||||
toast.error("Failed to delete issue");
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditComment = (c: Comment) => {
|
||||
setEditingCommentId(c.id);
|
||||
setEditContent(c.content);
|
||||
};
|
||||
|
||||
const handleSaveEditComment = async () => {
|
||||
if (!editingCommentId || !editContent.trim()) return;
|
||||
try {
|
||||
const updated = await api.updateComment(editingCommentId, editContent.trim());
|
||||
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
setEditingCommentId(null);
|
||||
} catch {
|
||||
toast.error("Failed to update comment");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId: string) => {
|
||||
try {
|
||||
await api.deleteComment(commentId);
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||
} catch {
|
||||
toast.error("Failed to delete comment");
|
||||
}
|
||||
};
|
||||
|
||||
// Real-time comment updates
|
||||
useWSEvent(
|
||||
"comment:created",
|
||||
useCallback((payload: unknown) => {
|
||||
const { comment } = payload as CommentCreatedPayload;
|
||||
if (comment.issue_id !== id) return;
|
||||
// Skip own comments — already added locally via API response
|
||||
if (comment.author_type === "member" && comment.author_id === user?.id) return;
|
||||
setComments((prev) => {
|
||||
if (prev.some((c) => c.id === comment.id)) return prev;
|
||||
return [...prev, comment];
|
||||
});
|
||||
}, [id, user?.id]),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"comment:updated",
|
||||
useCallback((payload: unknown) => {
|
||||
const { comment } = payload as CommentUpdatedPayload;
|
||||
if (comment.issue_id === id) {
|
||||
setComments((prev) => prev.map((c) => (c.id === comment.id ? comment : c)));
|
||||
}
|
||||
}, [id]),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"comment:deleted",
|
||||
useCallback((payload: unknown) => {
|
||||
const { comment_id, issue_id } = payload as CommentDeletedPayload;
|
||||
if (issue_id === id) {
|
||||
setComments((prev) => prev.filter((c) => c.id !== comment_id));
|
||||
}
|
||||
}, [id]),
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Issue not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* LEFT: Content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 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>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete issue</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this issue and all its comments. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<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>
|
||||
|
||||
{editingTitle ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() });
|
||||
setEditingTitle(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() });
|
||||
setEditingTitle(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
className="text-xl font-semibold leading-snug tracking-tight"
|
||||
/>
|
||||
) : (
|
||||
<h1
|
||||
className="text-xl font-semibold leading-snug tracking-tight cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
|
||||
onClick={() => { setTitleDraft(issue.title); setEditingTitle(true); }}
|
||||
>
|
||||
{issue.title}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<AcceptanceCriteriaEditor
|
||||
criteria={issue.acceptance_criteria}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
<ContextRefsEditor
|
||||
refs={issue.context_refs}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="my-8 border-t" />
|
||||
|
||||
{/* Activity / Comments */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Activity</h2>
|
||||
|
||||
<div className="mt-4">
|
||||
{comments.map((comment) => {
|
||||
const isOwn = comment.author_type === "member" && comment.author_id === user?.id;
|
||||
return (
|
||||
<div key={comment.id} className={`group relative py-3${comment.id.startsWith("temp-") ? " opacity-60" : ""}`}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ActorAvatar
|
||||
actorType={comment.author_type}
|
||||
actorId={comment.author_id}
|
||||
size={28}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{getActorName(comment.author_type, comment.author_id)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="text-xs text-muted-foreground cursor-default">
|
||||
{timeAgo(comment.created_at)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
{new Date(comment.created_at).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isOwn && (
|
||||
<div className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => startEditComment(comment)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editingCommentId === comment.id ? (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveEditComment(); }} className="mt-2 pl-9.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
aria-label="Edit comment"
|
||||
className="w-full text-sm bg-transparent border-b outline-none"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<div className="mt-2 pl-9.5 text-sm leading-relaxed text-foreground/85 whitespace-pre-wrap">
|
||||
{comment.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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)}
|
||||
placeholder="Leave a comment..."
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!commentText.trim() || submitting}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Properties sidebar */}
|
||||
<div className="w-60 shrink-0 overflow-y-auto border-l">
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Properties
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<PropRow label="Status">
|
||||
<StatusPicker status={issue.status} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Priority">
|
||||
<PriorityPicker priority={issue.priority} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Assignee">
|
||||
<AssigneePicker
|
||||
assigneeType={issue.assignee_type}
|
||||
assigneeId={issue.assignee_id}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Due date">
|
||||
<DueDatePicker dueDate={issue.due_date} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Created by">
|
||||
<ActorAvatar
|
||||
actorType={issue.creator_type}
|
||||
actorId={issue.creator_id}
|
||||
size={18}
|
||||
/>
|
||||
<span>{getActorName(issue.creator_type, issue.creator_id)}</span>
|
||||
</PropRow>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t pt-3 space-y-0.5">
|
||||
<PropRow label="Created">
|
||||
<span className="text-muted-foreground">{shortDate(issue.created_at)}</span>
|
||||
</PropRow>
|
||||
<PropRow label="Updated">
|
||||
<span className="text-muted-foreground">{shortDate(issue.updated_at)}</span>
|
||||
</PropRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
apps/web/features/issues/components/issues-header.tsx
Normal file
161
apps/web/features/issues/components/issues-header.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, Columns3, List, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import {
|
||||
ALL_STATUSES,
|
||||
STATUS_CONFIG,
|
||||
PRIORITY_ORDER,
|
||||
PRIORITY_CONFIG,
|
||||
} from "@/features/issues/config";
|
||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { useIssueViewStore } from "@/features/issues/stores/view-store";
|
||||
|
||||
function formatFilterLabel(
|
||||
prefix: string,
|
||||
selected: string[],
|
||||
configMap: Record<string, { label: string }>
|
||||
) {
|
||||
if (selected.length === 0) return `${prefix}: All`;
|
||||
if (selected.length === 1) {
|
||||
const key = selected[0];
|
||||
if (key) return `${prefix}: ${configMap[key]?.label ?? key}`;
|
||||
}
|
||||
return `${prefix}: ${selected.length} selected`;
|
||||
}
|
||||
|
||||
export function IssuesHeader() {
|
||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||
const statusFilters = useIssueViewStore((s) => s.statusFilters);
|
||||
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
|
||||
const setViewMode = useIssueViewStore((s) => s.setViewMode);
|
||||
const toggleStatusFilter = useIssueViewStore((s) => s.toggleStatusFilter);
|
||||
const togglePriorityFilter = useIssueViewStore((s) => s.togglePriorityFilter);
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="whitespace-nowrap text-xs">
|
||||
{formatFilterLabel("Status", statusFilters, STATUS_CONFIG)}
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
useIssueViewStore.setState({ statusFilters: [] })
|
||||
}
|
||||
>
|
||||
All
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={s}
|
||||
checked={statusFilters.includes(s)}
|
||||
onCheckedChange={() => toggleStatusFilter(s)}
|
||||
>
|
||||
<StatusIcon status={s} className="h-3.5 w-3.5" />
|
||||
{STATUS_CONFIG[s].label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Priority filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="whitespace-nowrap text-xs">
|
||||
{formatFilterLabel("Priority", priorityFilters, PRIORITY_CONFIG)}
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Priority</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
useIssueViewStore.setState({ priorityFilters: [] })
|
||||
}
|
||||
>
|
||||
All
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{PRIORITY_ORDER.map((p) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={p}
|
||||
checked={priorityFilters.includes(p)}
|
||||
onCheckedChange={() => togglePriorityFilter(p)}
|
||||
>
|
||||
<PriorityIcon priority={p} />
|
||||
{PRIORITY_CONFIG[p].label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
{viewMode === "board" ? <Columns3 /> : <List />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>View</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setViewMode("board")}>
|
||||
<Columns3 />
|
||||
Board
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setViewMode("list")}>
|
||||
<List />
|
||||
List
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* New issue */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => useModalStore.getState().open("create-issue")}
|
||||
>
|
||||
<Plus />
|
||||
New Issue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
apps/web/features/issues/components/issues-page.tsx
Normal file
133
apps/web/features/issues/components/issues-page.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { IssueStatus } from "@multica/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueViewStore } from "@/features/issues/stores/view-store";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import { IssuesHeader } from "./issues-header";
|
||||
import { BoardView } from "./board-view";
|
||||
import { ListView } from "./list-view";
|
||||
|
||||
const BOARD_STATUSES: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
];
|
||||
|
||||
export function IssuesPage() {
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||
const statusFilters = useIssueViewStore((s) => s.statusFilters);
|
||||
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
|
||||
const clearFilters = useIssueViewStore((s) => s.clearFilters);
|
||||
|
||||
const issues = useMemo(() => {
|
||||
return allIssues.filter((issue) => {
|
||||
if (statusFilters.length > 0 && !statusFilters.includes(issue.status))
|
||||
return false;
|
||||
if (
|
||||
priorityFilters.length > 0 &&
|
||||
!priorityFilters.includes(issue.priority)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}, [allIssues, statusFilters, priorityFilters]);
|
||||
|
||||
const visibleStatuses = useMemo(() => {
|
||||
if (statusFilters.length > 0)
|
||||
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
|
||||
return BOARD_STATUSES;
|
||||
}, [statusFilters]);
|
||||
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus) => {
|
||||
useIssueStore.getState().updateIssue(issueId, { status: newStatus });
|
||||
|
||||
api.updateIssue(issueId, { status: newStatus }).catch(() => {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
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">
|
||||
<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">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0 gap-3 overflow-x-auto p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{workspace?.name ?? "Workspace"}
|
||||
</span>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Issues</span>
|
||||
</div>
|
||||
|
||||
{/* Header 2: View toggle + filters */}
|
||||
<IssuesHeader />
|
||||
|
||||
{/* Content: scrollable */}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{issues.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<p>No matching issues</p>
|
||||
{(statusFilters.length > 0 || priorityFilters.length > 0) && (
|
||||
<button
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
apps/web/features/issues/components/list-row.tsx
Normal file
42
apps/web/features/issues/components/list-row.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Issue } from "@multica/types";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function ListRow({ issue }: { issue: Issue }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issue.id}`}
|
||||
className="flex h-9 items-center gap-2 px-4 text-sm transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">
|
||||
{issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
|
||||
{issue.due_date && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDate(issue.due_date)}
|
||||
</span>
|
||||
)}
|
||||
{issue.assignee_type && issue.assignee_id && (
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
34
apps/web/features/issues/components/list-view.tsx
Normal file
34
apps/web/features/issues/components/list-view.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import type { Issue } from "@multica/types";
|
||||
import { STATUS_ORDER, STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { ListRow } from "./list-row";
|
||||
|
||||
export function ListView({ issues }: { issues: Issue[] }) {
|
||||
const groupOrder = STATUS_ORDER.filter((s) => s !== "cancelled");
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
<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">
|
||||
{filtered.length}
|
||||
</span>
|
||||
</div>
|
||||
{filtered.map((issue) => (
|
||||
<ListRow key={issue.id} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ export function AssigneePicker({
|
|||
assigneeType && assigneeId ? (
|
||||
<>
|
||||
<div
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-[18px] ${
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4.5 ${
|
||||
assigneeType === "agent"
|
||||
? "bg-info/10 text-info"
|
||||
: "bg-muted text-muted-foreground"
|
||||
|
|
@ -103,7 +103,7 @@ export function AssigneePicker({
|
|||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
<div className="inline-flex size-4.5 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>
|
||||
|
|
@ -127,7 +127,7 @@ export function AssigneePicker({
|
|||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<span>{a.name}</span>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function PropertyPicker({
|
|||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label="Filter options"
|
||||
className="w-full bg-transparent text-[13px] placeholder:text-muted-foreground outline-none"
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -92,7 +92,7 @@ export function PickerItem({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[13px] ${hoverClassName ?? "hover:bg-accent"} transition-colors`}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${hoverClassName ?? "hover:bg-accent"} transition-colors`}
|
||||
>
|
||||
<span className="flex flex-1 items-center gap-2">{children}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
|
|
@ -113,7 +113,7 @@ export function PickerSection({
|
|||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 pt-2 pb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
|
|
@ -127,7 +127,7 @@ export function PickerSection({
|
|||
|
||||
export function PickerEmpty() {
|
||||
return (
|
||||
<div className="px-2 py-3 text-center text-[13px] text-muted-foreground">
|
||||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
||||
No results
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,21 +2,70 @@ import type { IssueStatus } from "@multica/types";
|
|||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Circle geometry constants (viewBox 0 0 16 16, center 8,8, radius 6)
|
||||
// Geometry constants (viewBox 0 0 14 14, center 7,7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CX = 8;
|
||||
const CY = 8;
|
||||
const R = 6;
|
||||
const CX = 7;
|
||||
const CY = 7;
|
||||
const OUTER_R = 6;
|
||||
const FILL_R = 3.5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-status SVG renderers — Linear-style icons
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a pie-wedge SVG path from 12 o'clock, clockwise */
|
||||
function piePath(cx: number, cy: number, r: number, progress: number): string {
|
||||
const angle = 2 * Math.PI * progress;
|
||||
const endX = cx + r * Math.sin(angle);
|
||||
const endY = cy - r * Math.cos(angle);
|
||||
const largeArc = progress > 0.5 ? 1 : 0;
|
||||
return `M${cx},${cy} L${cx},${cy - r} A${r},${r} 0 ${largeArc},1 ${endX},${endY} Z`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base component — dashed outer ring + pie fill + optional center icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProgressCircle({
|
||||
progress,
|
||||
children,
|
||||
}: {
|
||||
progress: number;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Outer dashed ring */}
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={OUTER_R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="3.14 0"
|
||||
strokeDashoffset={-0.7}
|
||||
/>
|
||||
{/* Progress fill */}
|
||||
{progress === 1 ? (
|
||||
<circle cx={CX} cy={CY} r={OUTER_R} fill="currentColor" />
|
||||
) : progress > 0 ? (
|
||||
<path d={piePath(CX, CY, FILL_R, progress)} fill="currentColor" />
|
||||
) : null}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-status renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 16 small dots arranged in a ring */
|
||||
function BacklogIcon() {
|
||||
const count = 16;
|
||||
const dotR = 0.65;
|
||||
const dotR = 0.55;
|
||||
return (
|
||||
<g>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
|
|
@ -24,8 +73,8 @@ function BacklogIcon() {
|
|||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={CX + R * Math.cos(angle)}
|
||||
cy={CY + R * Math.sin(angle)}
|
||||
cx={CX + OUTER_R * Math.cos(angle)}
|
||||
cy={CY + OUTER_R * Math.sin(angle)}
|
||||
r={dotR}
|
||||
fill="currentColor"
|
||||
/>
|
||||
|
|
@ -35,97 +84,58 @@ function BacklogIcon() {
|
|||
);
|
||||
}
|
||||
|
||||
/** Empty circle, solid outline */
|
||||
function TodoIcon() {
|
||||
return (
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
);
|
||||
return <ProgressCircle progress={0} />;
|
||||
}
|
||||
|
||||
/** Circle outline + right half filled (D-shape) */
|
||||
function InProgressIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d={`M${CX},${CY - R} A${R},${R} 0 0,1 ${CX},${CY + R} Z`}
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <ProgressCircle progress={0.5} />;
|
||||
}
|
||||
|
||||
/** Circle outline + 75% pie fill (bottom-left quarter empty) */
|
||||
function InReviewIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d={`M${CX},${CY} L${CX},${CY - R} A${R},${R} 0 1,1 ${CX - R},${CY} Z`}
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <ProgressCircle progress={0.75} />;
|
||||
}
|
||||
|
||||
/** Solid filled circle + white checkmark */
|
||||
function DoneIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle cx={CX} cy={CY} r={R} fill="currentColor" />
|
||||
<ProgressCircle progress={1}>
|
||||
<path
|
||||
d="M5.5 8.2 L7.2 9.8 L10.5 6.2"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.951 4.24896C11.283 4.58091 11.283 5.11909 10.951 5.45104L5.95104 10.451C5.61909 10.783 5.0809 10.783 4.74896 10.451L2.74896 8.45104C2.41701 8.11909 2.41701 7.5809 2.74896 7.24896C3.0809 6.91701 3.61909 6.91701 3.95104 7.24896L5.35 8.64792L9.74896 4.24896C10.0809 3.91701 10.6191 3.91701 10.951 4.24896Z"
|
||||
fill="white"
|
||||
stroke="none"
|
||||
/>
|
||||
</>
|
||||
</ProgressCircle>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circle outline + X inside */
|
||||
function CancelledIcon() {
|
||||
/** Outer ring + prohibition slash (🚫 style) */
|
||||
function BlockedIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
<ProgressCircle progress={0}>
|
||||
<line
|
||||
x1={CX + FILL_R * Math.cos(Math.PI * 0.75)}
|
||||
y1={CY - FILL_R * Math.sin(Math.PI * 0.75)}
|
||||
x2={CX + FILL_R * Math.cos(-Math.PI * 0.25)}
|
||||
y2={CY - FILL_R * Math.sin(-Math.PI * 0.25)}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M5.75 5.75 L10.25 10.25 M10.25 5.75 L5.75 10.25"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
</ProgressCircle>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelledIcon() {
|
||||
return (
|
||||
<ProgressCircle progress={0}>
|
||||
<path
|
||||
d="M5 5 L9 9 M9 5 L5 9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</ProgressCircle>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +149,7 @@ const STATUS_RENDERERS: Record<IssueStatus, () => React.ReactNode> = {
|
|||
in_progress: InProgressIcon,
|
||||
in_review: InReviewIcon,
|
||||
done: DoneIcon,
|
||||
blocked: CancelledIcon, // fallback if backend sends blocked
|
||||
blocked: BlockedIcon,
|
||||
cancelled: CancelledIcon,
|
||||
};
|
||||
|
||||
|
|
@ -159,7 +169,7 @@ export function StatusIcon({
|
|||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
className={`${className} ${cfg.iconColor} shrink-0`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { useIssueStore } from "./store";
|
||||
export { useIssueViewStore } from "./stores/view-store";
|
||||
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
|
||||
export * from "./config";
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
import { create } from "zustand";
|
||||
import type { Issue } from "@multica/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("issue-store");
|
||||
|
||||
interface IssueState {
|
||||
issues: Issue[];
|
||||
|
|
@ -22,14 +25,14 @@ export const useIssueStore = create<IssueState>((set) => ({
|
|||
activeIssueId: null,
|
||||
|
||||
fetch: async () => {
|
||||
console.log("[issue-store] fetch start");
|
||||
logger.debug("fetch start");
|
||||
set({ loading: true });
|
||||
try {
|
||||
const res = await api.listIssues({ limit: 200 });
|
||||
console.log("[issue-store] fetched", res.issues.length, "issues");
|
||||
logger.info("fetched", res.issues.length, "issues");
|
||||
set({ issues: res.issues, loading: false });
|
||||
} catch (err) {
|
||||
console.error("[issue-store] fetch failed", err);
|
||||
logger.error("fetch failed", err);
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
|
|
|||
50
apps/web/features/issues/stores/view-store.ts
Normal file
50
apps/web/features/issues/stores/view-store.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { IssueStatus, IssuePriority } from "@multica/types";
|
||||
|
||||
export type ViewMode = "board" | "list";
|
||||
|
||||
interface IssueViewState {
|
||||
viewMode: ViewMode;
|
||||
statusFilters: IssueStatus[];
|
||||
priorityFilters: IssuePriority[];
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
toggleStatusFilter: (status: IssueStatus) => void;
|
||||
togglePriorityFilter: (priority: IssuePriority) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
export const useIssueViewStore = create<IssueViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: "board",
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
statusFilters: state.statusFilters.includes(status)
|
||||
? state.statusFilters.filter((s) => s !== status)
|
||||
: [...state.statusFilters, status],
|
||||
})),
|
||||
togglePriorityFilter: (priority) =>
|
||||
set((state) => ({
|
||||
priorityFilters: state.priorityFilters.includes(priority)
|
||||
? state.priorityFilters.filter((p) => p !== priority)
|
||||
: [...state.priorityFilters, priority],
|
||||
})),
|
||||
clearFilters: () => set({ statusFilters: [], priorityFilters: [] }),
|
||||
}),
|
||||
{
|
||||
name: "multica_issues_view",
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
statusFilters: state.statusFilters,
|
||||
priorityFilters: state.priorityFilters,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
1
apps/web/features/navigation/index.ts
Normal file
1
apps/web/features/navigation/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useNavigationStore } from "./store";
|
||||
29
apps/web/features/navigation/store.ts
Normal file
29
apps/web/features/navigation/store.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
const EXCLUDED_PREFIXES = ["/login", "/pair/"];
|
||||
|
||||
interface NavigationState {
|
||||
lastPath: string;
|
||||
onPathChange: (path: string) => void;
|
||||
}
|
||||
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
|
||||
onPathChange: (path: string) => {
|
||||
if (!EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))) {
|
||||
set({ lastPath: path });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -13,6 +13,7 @@ import { WSClient } from "@multica/sdk";
|
|||
import type { WSEventType } from "@multica/types";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
import { useRealtimeSync } from "./use-realtime-sync";
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws";
|
||||
|
|
@ -37,7 +38,7 @@ export function WSProvider({ children }: { children: ReactNode }) {
|
|||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
|
||||
const ws = new WSClient(WS_URL);
|
||||
const ws = new WSClient(WS_URL, { logger: createLogger("ws") });
|
||||
ws.setAuth(token, workspace.id);
|
||||
wsRef.current = ws;
|
||||
setWsClient(ws);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useIssueStore } from "@/features/issues";
|
|||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
import type {
|
||||
IssueCreatedPayload,
|
||||
IssueUpdatedPayload,
|
||||
|
|
@ -23,6 +24,8 @@ import type {
|
|||
MemberRemovedPayload,
|
||||
} from "@multica/types";
|
||||
|
||||
const logger = createLogger("realtime-sync");
|
||||
|
||||
/**
|
||||
* Centralized WS → store sync. Called once from WSProvider.
|
||||
* Subscribes to all global WS events and dispatches to Zustand stores.
|
||||
|
|
@ -41,6 +44,7 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
ws.on("issue:updated", (p) => {
|
||||
const { issue } = p as IssueUpdatedPayload;
|
||||
useIssueStore.getState().updateIssue(issue.id, issue);
|
||||
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
|
||||
}),
|
||||
ws.on("issue:deleted", (p) => {
|
||||
const { issue_id } = p as IssueDeletedPayload;
|
||||
|
|
@ -72,6 +76,12 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
const { item_id } = p as InboxArchivedPayload;
|
||||
useInboxStore.getState().archive(item_id);
|
||||
}),
|
||||
ws.on("inbox:batch-read", () => {
|
||||
useInboxStore.getState().markAllRead();
|
||||
}),
|
||||
ws.on("inbox:batch-archived", () => {
|
||||
useInboxStore.getState().fetch();
|
||||
}),
|
||||
];
|
||||
|
||||
return () => unsubs.forEach((u) => u());
|
||||
|
|
@ -108,27 +118,27 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
const unsubs = [
|
||||
ws.on("workspace:updated", (p) => {
|
||||
const { workspace } = p as WorkspaceUpdatedPayload;
|
||||
console.log("[realtime-sync] workspace:updated", workspace.name);
|
||||
logger.debug("workspace:updated", workspace.name);
|
||||
useWorkspaceStore.getState().updateWorkspace(workspace);
|
||||
}),
|
||||
ws.on("workspace:deleted", (p) => {
|
||||
const { workspace_id } = p as WorkspaceDeletedPayload;
|
||||
const currentWs = useWorkspaceStore.getState().workspace;
|
||||
if (currentWs?.id === workspace_id) {
|
||||
console.log("[realtime-sync] current workspace deleted, switching away");
|
||||
logger.warn("current workspace deleted, switching");
|
||||
toast.info("This workspace was deleted");
|
||||
useWorkspaceStore.getState().refreshWorkspaces();
|
||||
}
|
||||
}),
|
||||
ws.on("member:updated", (p) => {
|
||||
const payload = p as MemberUpdatedPayload;
|
||||
console.log("[realtime-sync] member:updated", payload.member.email, payload.member.role);
|
||||
logger.debug("member:updated", payload.member.email, payload.member.role);
|
||||
useWorkspaceStore.getState().refreshMembers();
|
||||
}),
|
||||
ws.on("member:added", (p) => {
|
||||
const payload = p as MemberAddedPayload;
|
||||
const myUserId = useAuthStore.getState().user?.id;
|
||||
console.log("[realtime-sync] member:added", payload.member.email);
|
||||
logger.debug("member:added", payload.member.email);
|
||||
if (payload.member.user_id === myUserId) {
|
||||
// I was invited to a workspace — refresh list so it appears
|
||||
useWorkspaceStore.getState().refreshWorkspaces();
|
||||
|
|
@ -139,9 +149,9 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
ws.on("member:removed", (p) => {
|
||||
const payload = p as MemberRemovedPayload;
|
||||
const myUserId = useAuthStore.getState().user?.id;
|
||||
console.log("[realtime-sync] member:removed user_id:", payload.user_id);
|
||||
logger.debug("member:removed", payload.user_id);
|
||||
if (payload.user_id === myUserId) {
|
||||
console.log("[realtime-sync] I was removed, switching away");
|
||||
logger.warn("removed from workspace, switching");
|
||||
toast.info("You were removed from this workspace");
|
||||
useWorkspaceStore.getState().refreshWorkspaces();
|
||||
} else {
|
||||
|
|
@ -158,7 +168,7 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
if (!ws) return;
|
||||
|
||||
const unsub = ws.onReconnect(async () => {
|
||||
console.log("[realtime-sync] reconnected, refetching all data");
|
||||
logger.info("reconnected, refetching all data");
|
||||
try {
|
||||
await Promise.all([
|
||||
useIssueStore.getState().fetch(),
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ function SkillListItem({
|
|||
)}
|
||||
</div>
|
||||
{(skill.files?.length ?? 0) > 0 && (
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{skill.files.length} file{skill.files.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
const sizeMap = {
|
||||
sm: "h-5 w-5 text-[10px] rounded",
|
||||
sm: "h-5 w-5 text-xs rounded",
|
||||
md: "h-7 w-7 text-xs rounded-md",
|
||||
lg: "h-9 w-9 text-sm rounded-md",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import type { Workspace, MemberWithUser, Agent, Skill } from "@multica/types";
|
|||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("workspace-store");
|
||||
|
||||
interface WorkspaceState {
|
||||
workspace: Workspace | null;
|
||||
|
|
@ -70,7 +73,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
|
||||
set({ workspace: nextWorkspace });
|
||||
|
||||
console.log("[workspace-store] hydrate workspace:", nextWorkspace.name, nextWorkspace.id);
|
||||
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
||||
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
|
||||
api.listMembers(nextWorkspace.id),
|
||||
api.listAgents({ workspace_id: nextWorkspace.id }),
|
||||
|
|
@ -78,14 +81,14 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
useIssueStore.getState().fetch(),
|
||||
useInboxStore.getState().fetch(),
|
||||
]);
|
||||
console.log("[workspace-store] hydrate complete, members:", nextMembers.length, "agents:", nextAgents.length);
|
||||
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
|
||||
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
|
||||
|
||||
return nextWorkspace;
|
||||
},
|
||||
|
||||
switchWorkspace: async (workspaceId) => {
|
||||
console.log("[workspace-store] switching to", workspaceId);
|
||||
logger.info("switching to", workspaceId);
|
||||
const { workspaces, hydrateWorkspace } = get();
|
||||
const ws = workspaces.find((item) => item.id === workspaceId);
|
||||
if (!ws) return;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { ApiClient } from "@multica/sdk";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL);
|
||||
export const api = new ApiClient(API_BASE_URL, { logger: createLogger("api") });
|
||||
|
||||
// Initialize token from localStorage on load
|
||||
if (typeof window !== "undefined") {
|
||||
|
|
|
|||
51
apps/web/shared/logger.ts
Normal file
51
apps/web/shared/logger.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const COLORS: Record<LogLevel, string> = {
|
||||
debug: "color:#888",
|
||||
info: "color:#2196F3",
|
||||
warn: "color:#FF9800",
|
||||
error: "color:#F44336;font-weight:bold",
|
||||
};
|
||||
|
||||
const CONSOLE_METHOD: Record<LogLevel, "log" | "info" | "warn" | "error"> = {
|
||||
debug: "log",
|
||||
info: "info",
|
||||
warn: "warn",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
export interface Logger {
|
||||
debug(msg: string, ...data: unknown[]): void;
|
||||
info(msg: string, ...data: unknown[]): void;
|
||||
warn(msg: string, ...data: unknown[]): void;
|
||||
error(msg: string, ...data: unknown[]): void;
|
||||
}
|
||||
|
||||
export function createLogger(namespace: string): Logger {
|
||||
const make =
|
||||
(level: LogLevel) =>
|
||||
(msg: string, ...data: unknown[]) => {
|
||||
const ts = new Date().toISOString().slice(11, 23);
|
||||
const prefix = `%c${ts} [${namespace}]`;
|
||||
if (data.length > 0) {
|
||||
console[CONSOLE_METHOD[level]](prefix, COLORS[level], msg, ...data);
|
||||
} else {
|
||||
console[CONSOLE_METHOD[level]](prefix, COLORS[level], msg);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
debug: make("debug"),
|
||||
info: make("info"),
|
||||
warn: make("warn"),
|
||||
error: make("error"),
|
||||
};
|
||||
}
|
||||
|
||||
/** No-op logger for when logging is not needed. */
|
||||
export const noopLogger: Logger = {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
|
@ -24,6 +24,7 @@ import type {
|
|||
UpdateSkillRequest,
|
||||
SetAgentSkillsRequest,
|
||||
} from "@multica/types";
|
||||
import { type SDKLogger, noopLogger } from "./logger";
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
|
|
@ -34,9 +35,11 @@ export class ApiClient {
|
|||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private logger: SDKLogger;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
constructor(baseUrl: string, options?: { logger?: SDKLogger }) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
setToken(token: string | null) {
|
||||
|
|
@ -48,8 +51,13 @@ export class ApiClient {
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -59,6 +67,8 @@ export class ApiClient {
|
|||
headers["X-Workspace-ID"] = this.workspaceId;
|
||||
}
|
||||
|
||||
this.logger.info(`→ ${method} ${path}`, { rid });
|
||||
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
|
|
@ -74,9 +84,12 @@ export class ApiClient {
|
|||
} 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;
|
||||
|
|
@ -236,6 +249,22 @@ export class ApiClient {
|
|||
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");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export { ApiClient } from "./api-client";
|
||||
export type { LoginResponse } from "./api-client";
|
||||
export { WSClient } from "./ws-client";
|
||||
export { noopLogger } from "./logger";
|
||||
export type { SDKLogger } from "./logger";
|
||||
|
||||
export interface ContentBlock {
|
||||
type: "text" | "image" | "tool_use" | "tool_result";
|
||||
|
|
|
|||
13
packages/sdk/src/logger.ts
Normal file
13
packages/sdk/src/logger.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export interface SDKLogger {
|
||||
debug(msg: string, ...data: unknown[]): void;
|
||||
info(msg: string, ...data: unknown[]): void;
|
||||
warn(msg: string, ...data: unknown[]): void;
|
||||
error(msg: string, ...data: unknown[]): void;
|
||||
}
|
||||
|
||||
export const noopLogger: SDKLogger = {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WSMessage, WSEventType } from "@multica/types";
|
||||
import { type SDKLogger, noopLogger } from "./logger";
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
|
|
@ -11,9 +12,11 @@ export class WSClient {
|
|||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private hasConnectedBefore = false;
|
||||
private onReconnectCallbacks = new Set<() => void>();
|
||||
private logger: SDKLogger;
|
||||
|
||||
constructor(url: string) {
|
||||
constructor(url: string, options?: { logger?: SDKLogger }) {
|
||||
this.baseUrl = url;
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
setAuth(token: string, workspaceId: string) {
|
||||
|
|
@ -30,7 +33,7 @@ export class WSClient {
|
|||
this.ws = new WebSocket(url.toString());
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("[ws] connected");
|
||||
this.logger.info("connected");
|
||||
if (this.hasConnectedBefore) {
|
||||
for (const cb of this.onReconnectCallbacks) {
|
||||
try {
|
||||
|
|
@ -45,19 +48,19 @@ export class WSClient {
|
|||
|
||||
this.ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string) as WSMessage;
|
||||
console.log("[ws] received:", msg.type);
|
||||
this.logger.debug("received", msg.type);
|
||||
const eventHandlers = this.handlers.get(msg.type);
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
handler(msg.payload);
|
||||
}
|
||||
} else {
|
||||
console.log("[ws] no handlers registered for:", msg.type);
|
||||
this.logger.debug("unhandled event", msg.type);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log("[ws] disconnected, reconnecting in 3s...");
|
||||
this.logger.warn("disconnected, reconnecting in 3s");
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ export type WSEventType =
|
|||
| "inbox:new"
|
||||
| "inbox:read"
|
||||
| "inbox:archived"
|
||||
| "inbox:batch-read"
|
||||
| "inbox:batch-archived"
|
||||
| "workspace:updated"
|
||||
| "workspace:deleted"
|
||||
| "member:added"
|
||||
|
|
@ -77,6 +79,16 @@ export interface InboxArchivedPayload {
|
|||
recipient_id: string;
|
||||
}
|
||||
|
||||
export interface InboxBatchReadPayload {
|
||||
recipient_id: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface InboxBatchArchivedPayload {
|
||||
recipient_id: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CommentCreatedPayload {
|
||||
comment: Comment;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { IssueStatus } from "./issue";
|
||||
|
||||
export type InboxSeverity = "action_required" | "attention" | "info";
|
||||
|
||||
export type InboxItemType =
|
||||
|
|
@ -13,11 +15,14 @@ export interface InboxItem {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -3,16 +3,19 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger.Init()
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run ./cmd/migrate <up|down>")
|
||||
os.Exit(1)
|
||||
|
|
@ -32,12 +35,14 @@ func main() {
|
|||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to connect to database: %v", err)
|
||||
slog.Error("unable to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
log.Fatalf("Unable to ping database: %v", err)
|
||||
slog.Error("unable to ping database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create migrations tracking table
|
||||
|
|
@ -48,7 +53,8 @@ func main() {
|
|||
)
|
||||
`)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create migrations table: %v", err)
|
||||
slog.Error("failed to create migrations table", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Find migration files
|
||||
|
|
@ -61,7 +67,8 @@ func main() {
|
|||
suffix := "." + direction + ".sql"
|
||||
files, err := filepath.Glob(filepath.Join(migrationsDir, "*"+suffix))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to find migration files: %v", err)
|
||||
slog.Error("failed to find migration files", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if direction == "up" {
|
||||
|
|
@ -78,7 +85,8 @@ func main() {
|
|||
var exists bool
|
||||
err := pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to check migration status: %v", err)
|
||||
slog.Error("failed to check migration status", "version", version, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if exists {
|
||||
fmt.Printf(" skip %s (already applied)\n", version)
|
||||
|
|
@ -89,7 +97,8 @@ func main() {
|
|||
var exists bool
|
||||
err := pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to check migration status: %v", err)
|
||||
slog.Error("failed to check migration status", "version", version, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if !exists {
|
||||
fmt.Printf(" skip %s (not applied)\n", version)
|
||||
|
|
@ -99,12 +108,14 @@ func main() {
|
|||
|
||||
sql, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read %s: %v", file, err)
|
||||
slog.Error("failed to read migration file", "file", file, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
_, err = pool.Exec(ctx, string(sql))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to run %s: %v", file, err)
|
||||
slog.Error("failed to run migration", "file", file, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if direction == "up" {
|
||||
|
|
@ -113,7 +124,8 @@ func main() {
|
|||
_, err = pool.Exec(ctx, "DELETE FROM schema_migrations WHERE version = $1", version)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to record migration %s: %v", version, err)
|
||||
slog.Error("failed to record migration", "version", version, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", direction, version)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
|
|
@ -12,6 +10,7 @@ import (
|
|||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
"github.com/multica-ai/multica/server/internal/daemon"
|
||||
logger_pkg "github.com/multica-ai/multica/server/internal/logger"
|
||||
)
|
||||
|
||||
var daemonCmd = &cobra.Command{
|
||||
|
|
@ -61,7 +60,7 @@ func runDaemon(cmd *cobra.Command, _ []string) error {
|
|||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger := log.New(os.Stdout, "multica-daemon: ", log.LstdFlags)
|
||||
logger := logger_pkg.NewLogger("daemon")
|
||||
d := daemon.New(cfg, logger)
|
||||
|
||||
if err := d.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -33,6 +34,7 @@ func init() {
|
|||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/handler"
|
||||
|
|
@ -38,18 +38,23 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
IssueID: parseUUID(issue.ID),
|
||||
Title: "New issue assigned: " + issue.Title,
|
||||
Body: util.PtrToText(issue.Description),
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[inbox-listener] issue:created inbox error: %v", err)
|
||||
slog.Error("inbox item creation failed", "event", "issue:created", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := inboxItemToResponse(item)
|
||||
resp["issue_status"] = issue.Status
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: e.ActorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(item)},
|
||||
Payload: map[string]any{"item": resp},
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -84,14 +89,18 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
Severity: "info",
|
||||
IssueID: parseUUID(issue.ID),
|
||||
Title: "Unassigned from: " + issue.Title,
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err == nil {
|
||||
oldResp := inboxItemToResponse(oldItem)
|
||||
oldResp["issue_status"] = issue.Status
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: actorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(oldItem)},
|
||||
Payload: map[string]any{"item": oldResp},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -106,14 +115,18 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
Severity: "action_required",
|
||||
IssueID: parseUUID(issue.ID),
|
||||
Title: "Assigned to you: " + issue.Title,
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err == nil {
|
||||
newResp := inboxItemToResponse(newItem)
|
||||
newResp["issue_status"] = issue.Status
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: actorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(newItem)},
|
||||
Payload: map[string]any{"item": newResp},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -130,14 +143,18 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
Severity: "info",
|
||||
IssueID: parseUUID(issue.ID),
|
||||
Title: issue.Title + " moved to " + issue.Status,
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err == nil {
|
||||
aResp := inboxItemToResponse(aItem)
|
||||
aResp["issue_status"] = issue.Status
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: actorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(aItem)},
|
||||
Payload: map[string]any{"item": aResp},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -155,14 +172,18 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
Severity: "info",
|
||||
IssueID: parseUUID(issue.ID),
|
||||
Title: "Status changed: " + issue.Title,
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err == nil {
|
||||
cResp := inboxItemToResponse(cItem)
|
||||
cResp["issue_status"] = issue.Status
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: actorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(cItem)},
|
||||
Payload: map[string]any{"item": cResp},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -183,6 +204,7 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
issueTitle, _ := payload["issue_title"].(string)
|
||||
issueAssigneeType, _ := payload["issue_assignee_type"].(*string)
|
||||
issueAssigneeID, _ := payload["issue_assignee_id"].(*string)
|
||||
issueStatus, _ := payload["issue_status"].(string)
|
||||
|
||||
// Only notify if assignee is a member and is not the commenter
|
||||
if issueAssigneeType == nil || issueAssigneeID == nil {
|
||||
|
|
@ -201,18 +223,23 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
IssueID: parseUUID(comment.IssueID),
|
||||
Title: "New comment on: " + issueTitle,
|
||||
Body: util.StrToText(comment.Content),
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[inbox-listener] comment:created inbox error: %v", err)
|
||||
slog.Error("inbox item creation failed", "event", "comment:created", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
commentResp := inboxItemToResponse(item)
|
||||
commentResp["issue_status"] = issueStatus
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: e.ActorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(item)},
|
||||
Payload: map[string]any{"item": commentResp},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -233,5 +260,7 @@ func inboxItemToResponse(item db.InboxItem) map[string]any {
|
|||
"read": item.Read,
|
||||
"archived": item.Archived,
|
||||
"created_at": util.TimestampToString(item.CreatedAt),
|
||||
"actor_type": util.TextToPtr(item.ActorType),
|
||||
"actor_id": util.UUIDToPtr(item.ActorID),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
|
|
@ -44,7 +44,7 @@ func registerListeners(bus *events.Bus, hub *realtime.Hub) {
|
|||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("[listeners] failed to marshal %s event: %v", eventType, err)
|
||||
slog.Error("failed to marshal event", "event_type", eventType, "error", err)
|
||||
return
|
||||
}
|
||||
if e.WorkspaceID != "" {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
|
@ -12,11 +12,14 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger.Init()
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
|
|
@ -31,14 +34,16 @@ func main() {
|
|||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to connect to database: %v", err)
|
||||
slog.Error("unable to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
log.Fatalf("Unable to ping database: %v", err)
|
||||
slog.Error("unable to ping database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Println("Connected to database")
|
||||
slog.Info("connected to database")
|
||||
|
||||
bus := events.New()
|
||||
hub := realtime.NewHub()
|
||||
|
|
@ -57,9 +62,10 @@ func main() {
|
|||
|
||||
// Graceful shutdown
|
||||
go func() {
|
||||
log.Printf("Server starting on :%s", port)
|
||||
slog.Info("server starting", "port", port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
slog.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
@ -67,12 +73,13 @@ func main() {
|
|||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
slog.Info("shutting down server")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
slog.Error("server forced to shutdown", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Println("Server stopped")
|
||||
slog.Info("server stopped")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,13 +50,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(middleware.RequestLogger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: allowedOrigins(),
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
|
@ -159,6 +159,10 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Route("/api/inbox", func(r chi.Router) {
|
||||
r.Get("/", h.ListInbox)
|
||||
r.Get("/unread-count", h.CountUnreadInbox)
|
||||
r.Post("/mark-all-read", h.MarkAllInboxRead)
|
||||
r.Post("/archive-all", h.ArchiveAllInbox)
|
||||
r.Post("/archive-all-read", h.ArchiveAllReadInbox)
|
||||
r.Post("/archive-completed", h.ArchiveCompletedInbox)
|
||||
r.Post("/{id}/read", h.MarkInboxRead)
|
||||
r.Post("/{id}/archive", h.ArchiveInboxItem)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ require (
|
|||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/lmittmann/tint v1.1.3 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
|||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
||||
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package daemon
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -15,11 +15,11 @@ import (
|
|||
type Daemon struct {
|
||||
cfg Config
|
||||
client *Client
|
||||
logger *log.Logger
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Daemon instance.
|
||||
func New(cfg Config, logger *log.Logger) *Daemon {
|
||||
func New(cfg Config, logger *slog.Logger) *Daemon {
|
||||
return &Daemon{
|
||||
cfg: cfg,
|
||||
client: NewClient(cfg.ServerBaseURL),
|
||||
|
|
@ -33,8 +33,7 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||
for name := range d.cfg.Agents {
|
||||
agentNames = append(agentNames, name)
|
||||
}
|
||||
d.logger.Printf("starting daemon agents=%v workspace=%s server=%s repos_root=%s",
|
||||
agentNames, d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.ReposRoot)
|
||||
d.logger.Info("starting daemon", "agents", agentNames, "workspace_id", d.cfg.WorkspaceID, "server", d.cfg.ServerBaseURL, "repos_root", d.cfg.ReposRoot)
|
||||
|
||||
if strings.TrimSpace(d.cfg.WorkspaceID) == "" {
|
||||
workspaceID, err := d.ensurePaired(ctx)
|
||||
|
|
@ -42,7 +41,7 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
d.cfg.WorkspaceID = workspaceID
|
||||
d.logger.Printf("pairing completed for workspace=%s", workspaceID)
|
||||
d.logger.Info("pairing completed", "workspace_id", workspaceID)
|
||||
}
|
||||
|
||||
runtimes, err := d.registerRuntimes(ctx)
|
||||
|
|
@ -51,7 +50,7 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||
}
|
||||
runtimeIDs := make([]string, 0, len(runtimes))
|
||||
for _, rt := range runtimes {
|
||||
d.logger.Printf("registered runtime id=%s provider=%s status=%s", rt.ID, rt.Provider, rt.Status)
|
||||
d.logger.Info("registered runtime", "id", rt.ID, "provider", rt.Provider, "status", rt.Status)
|
||||
runtimeIDs = append(runtimeIDs, rt.ID)
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +63,7 @@ func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) {
|
|||
for name, entry := range d.cfg.Agents {
|
||||
version, err := agent.DetectVersion(ctx, entry.Path)
|
||||
if err != nil {
|
||||
d.logger.Printf("skip registering %s: %v", name, err)
|
||||
d.logger.Warn("skip registering runtime", "name", name, "error", err)
|
||||
continue
|
||||
}
|
||||
runtimes = append(runtimes, map[string]string{
|
||||
|
|
@ -122,9 +121,9 @@ func (d *Daemon) ensurePaired(ctx context.Context) (string, error) {
|
|||
return "", fmt.Errorf("create pairing session: %w", err)
|
||||
}
|
||||
if session.LinkURL != nil {
|
||||
d.logger.Printf("open this link to pair the daemon: %s", *session.LinkURL)
|
||||
d.logger.Info("open this link to pair the daemon", "url", *session.LinkURL)
|
||||
} else {
|
||||
d.logger.Printf("pairing session created: %s", session.Token)
|
||||
d.logger.Info("pairing session created", "token", session.Token)
|
||||
}
|
||||
|
||||
for {
|
||||
|
|
@ -176,7 +175,7 @@ func (d *Daemon) heartbeatLoop(ctx context.Context, runtimeIDs []string) {
|
|||
case <-ticker.C:
|
||||
for _, rid := range runtimeIDs {
|
||||
if err := d.client.SendHeartbeat(ctx, rid); err != nil {
|
||||
d.logger.Printf("heartbeat failed for runtime %s: %v", rid, err)
|
||||
d.logger.Warn("heartbeat failed", "runtime_id", rid, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -199,11 +198,11 @@ func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error {
|
|||
rid := runtimeIDs[(pollOffset+i)%n]
|
||||
task, err := d.client.ClaimTask(ctx, rid)
|
||||
if err != nil {
|
||||
d.logger.Printf("claim task failed for runtime %s: %v", rid, err)
|
||||
d.logger.Warn("claim task failed", "runtime_id", rid, "error", err)
|
||||
continue
|
||||
}
|
||||
if task != nil {
|
||||
d.logger.Printf("poll: got task=%s issue=%s title=%q", task.ID, task.IssueID, task.Context.Issue.Title)
|
||||
d.logger.Info("task received", "task_id", task.ID, "issue_id", task.IssueID, "title", task.Context.Issue.Title)
|
||||
d.handleTask(ctx, *task)
|
||||
claimed = true
|
||||
pollOffset = (pollOffset + i + 1) % n
|
||||
|
|
@ -214,7 +213,7 @@ func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error {
|
|||
if !claimed {
|
||||
pollCount++
|
||||
if pollCount%20 == 1 {
|
||||
d.logger.Printf("poll: no tasks (runtimes=%v, cycle=%d)", runtimeIDs, pollCount)
|
||||
d.logger.Debug("poll: no tasks", "runtimes", runtimeIDs, "cycle", pollCount)
|
||||
}
|
||||
pollOffset = (pollOffset + 1) % n
|
||||
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
|
||||
|
|
@ -228,10 +227,10 @@ func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error {
|
|||
|
||||
func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
||||
provider := task.Context.Runtime.Provider
|
||||
d.logger.Printf("picked task=%s issue=%s provider=%s title=%q", task.ID, task.IssueID, provider, task.Context.Issue.Title)
|
||||
d.logger.Info("picked task", "task_id", task.ID, "issue_id", task.IssueID, "provider", provider, "title", task.Context.Issue.Title)
|
||||
|
||||
if err := d.client.StartTask(ctx, task.ID); err != nil {
|
||||
d.logger.Printf("start task %s failed: %v", task.ID, err)
|
||||
d.logger.Error("start task failed", "task_id", task.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -239,9 +238,9 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
|||
|
||||
result, err := d.runTask(ctx, task)
|
||||
if err != nil {
|
||||
d.logger.Printf("task %s failed: %v", task.ID, err)
|
||||
d.logger.Error("task failed", "task_id", task.ID, "error", err)
|
||||
if failErr := d.client.FailTask(ctx, task.ID, err.Error()); failErr != nil {
|
||||
d.logger.Printf("fail task %s callback failed: %v", task.ID, failErr)
|
||||
d.logger.Error("fail task callback failed", "task_id", task.ID, "error", failErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -251,12 +250,12 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
|||
switch result.Status {
|
||||
case "blocked":
|
||||
if err := d.client.FailTask(ctx, task.ID, result.Comment); err != nil {
|
||||
d.logger.Printf("report blocked task %s failed: %v", task.ID, err)
|
||||
d.logger.Error("report blocked task failed", "task_id", task.ID, "error", err)
|
||||
}
|
||||
default:
|
||||
d.logger.Printf("task %s completed status=%s", task.ID, result.Status)
|
||||
d.logger.Info("task completed", "task_id", task.ID, "status", result.Status)
|
||||
if err := d.client.CompleteTask(ctx, task.ID, result.Comment, result.BranchName); err != nil {
|
||||
d.logger.Printf("complete task %s failed: %v", task.ID, err)
|
||||
d.logger.Error("complete task failed", "task_id", task.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -291,11 +290,11 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) {
|
|||
|
||||
// Inject runtime-specific config (meta skill) so the agent discovers .agent_context/.
|
||||
if err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx); err != nil {
|
||||
d.logger.Printf("execenv: inject runtime config failed (non-fatal): %v", err)
|
||||
d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err)
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := env.Cleanup(!d.cfg.KeepEnvAfterTask); cleanupErr != nil {
|
||||
d.logger.Printf("cleanup env for task %s: %v", task.ID, cleanupErr)
|
||||
d.logger.Warn("cleanup env failed", "task_id", task.ID, "error", cleanupErr)
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
@ -309,10 +308,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) {
|
|||
return TaskResult{}, fmt.Errorf("create agent backend: %w", err)
|
||||
}
|
||||
|
||||
d.logger.Printf(
|
||||
"starting %s task=%s workdir=%s branch=%s env_type=%s model=%s timeout=%s",
|
||||
provider, task.ID, env.WorkDir, env.BranchName, env.Type, entry.Model, d.cfg.AgentTimeout,
|
||||
)
|
||||
d.logger.Info("starting agent", "provider", provider, "task_id", task.ID, "workdir", env.WorkDir, "branch", env.BranchName, "env_type", env.Type, "model", entry.Model, "timeout", d.cfg.AgentTimeout.String())
|
||||
|
||||
session, err := backend.Execute(ctx, prompt, agent.ExecOptions{
|
||||
Cwd: env.WorkDir,
|
||||
|
|
@ -328,9 +324,9 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) {
|
|||
for msg := range session.Messages {
|
||||
switch msg.Type {
|
||||
case agent.MessageToolUse:
|
||||
d.logger.Printf("[%s] tool-use: %s (call=%s)", provider, msg.Tool, msg.CallID)
|
||||
d.logger.Debug("tool-use", "provider", provider, "tool", msg.Tool, "call_id", msg.CallID)
|
||||
case agent.MessageError:
|
||||
d.logger.Printf("[%s] error: %s", provider, msg.Content)
|
||||
d.logger.Error("agent error", "provider", provider, "content", msg.Content)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ package execenv
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
|
@ -63,11 +63,11 @@ type Environment struct {
|
|||
BranchName string
|
||||
|
||||
gitRoot string // source repo root (for cleanup)
|
||||
logger *log.Logger // for cleanup logging
|
||||
logger *slog.Logger // for cleanup logging
|
||||
}
|
||||
|
||||
// Prepare creates an isolated execution environment for a task.
|
||||
func Prepare(params PrepareParams, logger *log.Logger) (*Environment, error) {
|
||||
func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
|
||||
if params.WorkspacesRoot == "" {
|
||||
return nil, fmt.Errorf("execenv: workspaces root is required")
|
||||
}
|
||||
|
|
@ -108,7 +108,7 @@ func Prepare(params PrepareParams, logger *log.Logger) (*Environment, error) {
|
|||
baseRef := getDefaultBranch(gitRoot)
|
||||
|
||||
if err := setupGitWorktree(gitRoot, workDir, branchName, baseRef); err != nil {
|
||||
logger.Printf("execenv: git worktree setup failed, falling back to directory mode: %v", err)
|
||||
logger.Warn("execenv: git worktree setup failed, falling back to directory mode", "error", err)
|
||||
} else {
|
||||
env.Type = WorkspaceTypeGitWorktree
|
||||
env.BranchName = branchName
|
||||
|
|
@ -117,7 +117,7 @@ func Prepare(params PrepareParams, logger *log.Logger) (*Environment, error) {
|
|||
// Exclude injected directories from git tracking.
|
||||
for _, pattern := range []string{".agent_context", ".claude", "AGENTS.md"} {
|
||||
if err := excludeFromGit(workDir, pattern); err != nil {
|
||||
logger.Printf("execenv: failed to exclude %s from git: %v", pattern, err)
|
||||
logger.Warn("execenv: failed to exclude from git", "pattern", pattern, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -129,7 +129,7 @@ func Prepare(params PrepareParams, logger *log.Logger) (*Environment, error) {
|
|||
return nil, fmt.Errorf("execenv: write context files: %w", err)
|
||||
}
|
||||
|
||||
logger.Printf("execenv: prepared env root=%s type=%s branch=%s", envRoot, env.Type, env.BranchName)
|
||||
logger.Info("execenv: prepared env", "root", envRoot, "type", env.Type, "branch", env.BranchName)
|
||||
return env, nil
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +148,7 @@ func (env *Environment) Cleanup(removeAll bool) error {
|
|||
|
||||
if removeAll {
|
||||
if err := os.RemoveAll(env.RootDir); err != nil {
|
||||
env.logger.Printf("execenv: cleanup removeAll failed: %v", err)
|
||||
env.logger.Warn("execenv: cleanup removeAll failed", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
@ -156,7 +156,7 @@ func (env *Environment) Cleanup(removeAll bool) error {
|
|||
|
||||
// Partial cleanup: remove workdir, keep output/ and logs/.
|
||||
if err := os.RemoveAll(env.WorkDir); err != nil {
|
||||
env.logger.Printf("execenv: cleanup workdir failed: %v", err)
|
||||
env.logger.Warn("execenv: cleanup workdir failed", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package execenv
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -9,8 +9,8 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func testLogger() *log.Logger {
|
||||
return log.New(os.Stderr, "[test] ", log.LstdFlags)
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
func TestShortID(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package execenv
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -57,18 +57,18 @@ func runGitWorktreeAdd(gitRoot, worktreePath, branchName, baseRef string) error
|
|||
}
|
||||
|
||||
// removeGitWorktree removes a worktree and its branch. Best-effort: logs errors.
|
||||
func removeGitWorktree(gitRoot, worktreePath, branchName string, logger *log.Logger) {
|
||||
func removeGitWorktree(gitRoot, worktreePath, branchName string, logger *slog.Logger) {
|
||||
// Remove the worktree.
|
||||
cmd := exec.Command("git", "-C", gitRoot, "worktree", "remove", "--force", worktreePath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
logger.Printf("execenv: git worktree remove: %s: %v", strings.TrimSpace(string(out)), err)
|
||||
logger.Warn("execenv: git worktree remove failed", "output", strings.TrimSpace(string(out)), "error", err)
|
||||
}
|
||||
|
||||
// Delete the branch (best-effort).
|
||||
if branchName != "" {
|
||||
cmd = exec.Command("git", "-C", gitRoot, "branch", "-D", branchName)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
logger.Printf("execenv: git branch -D %s: %s: %v", branchName, strings.TrimSpace(string(out)), err)
|
||||
logger.Warn("execenv: git branch delete failed", "branch", branchName, "output", strings.TrimSpace(string(out)), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package events
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"sync"
|
||||
)
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ func (b *Bus) Publish(e Event) {
|
|||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[event-bus] panic in listener for %q: %v", e.Type, r)
|
||||
slog.Error("panic in event listener", "event_type", e.Type, "recovered", r)
|
||||
}
|
||||
}()
|
||||
h(e)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
|
@ -276,9 +277,11 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
Triggers: triggers,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("create agent failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
slog.Info("agent created", append(logger.RequestAttrs(r), "agent_id", uuidToString(agent.ID), "name", agent.Name, "workspace_id", workspaceID)...)
|
||||
|
||||
if runtime.Status == "online" {
|
||||
h.TaskService.ReconcileAgentStatus(r.Context(), agent.ID)
|
||||
|
|
@ -331,7 +334,7 @@ func (h *Handler) createAgentInitIssue(ctx context.Context, agent db.Agent, crea
|
|||
|
||||
// Enqueue the task directly — we know the agent is assigned and status is "todo".
|
||||
if _, err := h.TaskService.EnqueueTaskForIssue(ctx, issue); err != nil {
|
||||
log.Printf("createAgentInitIssue: enqueue task failed for issue %s: %v", issue.Title, err)
|
||||
slog.Warn("createAgentInitIssue: enqueue task failed", "issue_title", issue.Title, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -413,11 +416,13 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
agent, err := h.Queries.UpdateAgent(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Warn("update agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := agentToResponse(agent)
|
||||
slog.Info("agent updated", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", uuidToString(agent.WorkspaceID))...)
|
||||
userID := requestUserID(r)
|
||||
h.publish(protocol.EventAgentStatus, uuidToString(agent.WorkspaceID), "member", userID, map[string]any{"agent": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
|
@ -438,10 +443,12 @@ func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
err := h.Queries.DeleteAgent(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
slog.Warn("delete agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete agent")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("agent deleted", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...)
|
||||
userID := requestUserID(r)
|
||||
h.publish(protocol.EventAgentDeleted, wsID, "member", userID, map[string]any{"agent_id": id, "workspace_id": wsID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handler
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -10,6 +11,7 @@ import (
|
|||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
|
|
@ -167,6 +169,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
|
||||
if err != nil {
|
||||
if !isNotFound(err) {
|
||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
|
|
@ -181,9 +184,11 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
Email: req.Email,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
||||
return
|
||||
}
|
||||
slog.Info("new user created", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
|
||||
} else if req.Name != "" && req.Name != user.Name {
|
||||
user, err = h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{
|
||||
ID: user.ID,
|
||||
|
|
@ -196,6 +201,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
|
||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
|
||||
return
|
||||
}
|
||||
|
|
@ -211,10 +217,12 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
tokenString, err := token.SignedString(auth.JWTSecret())
|
||||
if err != nil {
|
||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("user logged in", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
|
||||
writeJSON(w, http.StatusOK, LoginResponse{
|
||||
Token: tokenString,
|
||||
User: userToResponse(user),
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ package handler
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
|
@ -93,16 +95,19 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
Type: req.Type,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("create comment failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create comment: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := commentToResponse(comment)
|
||||
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
|
||||
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{
|
||||
"comment": resp,
|
||||
"issue_title": issue.Title,
|
||||
"issue_assignee_type": textToPtr(issue.AssigneeType),
|
||||
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
|
||||
"comment": resp,
|
||||
"issue_title": issue.Title,
|
||||
"issue_assignee_type": textToPtr(issue.AssigneeType),
|
||||
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
|
||||
"issue_status": issue.Status,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
|
|
@ -159,11 +164,13 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
|||
Content: req.Content,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("update comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update comment")
|
||||
return
|
||||
}
|
||||
|
||||
resp := commentToResponse(comment)
|
||||
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
|
||||
h.publish(protocol.EventCommentUpdated, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{"comment": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
|
@ -203,10 +210,12 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil {
|
||||
slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete comment")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...)
|
||||
h.publish(protocol.EventCommentDeleted, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{
|
||||
"comment_id": commentId,
|
||||
"issue_id": uuidToString(comment.IssueID),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handler
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
|
|
@ -99,6 +100,8 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
|||
resp = append(resp, runtimeToResponse(registered))
|
||||
}
|
||||
|
||||
slog.Info("daemon registered", "workspace_id", req.WorkspaceID, "daemon_id", req.DaemonID, "runtimes_count", len(resp))
|
||||
|
||||
h.publish(protocol.EventDaemonRegister, req.WorkspaceID, "system", "", map[string]any{
|
||||
"runtimes": resp,
|
||||
})
|
||||
|
|
@ -128,6 +131,7 @@ func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
slog.Debug("daemon heartbeat", "runtime_id", req.RuntimeID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
|
|
@ -142,10 +146,12 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if task == nil {
|
||||
slog.Debug("no task to claim", "runtime_id", runtimeID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": nil})
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("task claimed by runtime", "task_id", uuidToString(task.ID), "runtime_id", runtimeID, "agent_id", uuidToString(task.AgentID))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": taskToResponse(*task)})
|
||||
}
|
||||
|
||||
|
|
@ -177,10 +183,12 @@ func (h *Handler) StartTask(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
task, err := h.TaskService.StartTask(r.Context(), parseUUID(taskID))
|
||||
if err != nil {
|
||||
slog.Warn("start task failed", "task_id", taskID, "error", err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("task started", "task_id", taskID, "agent_id", uuidToString(task.AgentID))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
}
|
||||
|
||||
|
|
@ -231,10 +239,12 @@ func (h *Handler) CompleteTask(w http.ResponseWriter, r *http.Request) {
|
|||
result, _ := json.Marshal(req)
|
||||
task, err := h.TaskService.CompleteTask(r.Context(), parseUUID(taskID), result)
|
||||
if err != nil {
|
||||
slog.Warn("complete task failed", "task_id", taskID, "error", err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("task completed", "task_id", taskID, "agent_id", uuidToString(task.AgentID))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
}
|
||||
|
||||
|
|
@ -254,9 +264,11 @@ func (h *Handler) FailTask(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
task, err := h.TaskService.FailTask(r.Context(), parseUUID(taskID), req.Error)
|
||||
if err != nil {
|
||||
slog.Warn("fail task failed", "task_id", taskID, "error", err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("task failed", "task_id", taskID, "agent_id", uuidToString(task.AgentID), "task_error", req.Error)
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
|
@ -22,6 +24,9 @@ type InboxItemResponse struct {
|
|||
Read bool `json:"read"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IssueStatus *string `json:"issue_status"`
|
||||
ActorType *string `json:"actor_type"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
}
|
||||
|
||||
func inboxToResponse(i db.InboxItem) InboxItemResponse {
|
||||
|
|
@ -38,6 +43,28 @@ func inboxToResponse(i db.InboxItem) InboxItemResponse {
|
|||
Read: i.Read,
|
||||
Archived: i.Archived,
|
||||
CreatedAt: timestampToString(i.CreatedAt),
|
||||
ActorType: textToPtr(i.ActorType),
|
||||
ActorID: uuidToPtr(i.ActorID),
|
||||
}
|
||||
}
|
||||
|
||||
func inboxRowToResponse(r db.ListInboxItemsRow) InboxItemResponse {
|
||||
return InboxItemResponse{
|
||||
ID: uuidToString(r.ID),
|
||||
WorkspaceID: uuidToString(r.WorkspaceID),
|
||||
RecipientType: r.RecipientType,
|
||||
RecipientID: uuidToString(r.RecipientID),
|
||||
Type: r.Type,
|
||||
Severity: r.Severity,
|
||||
IssueID: uuidToPtr(r.IssueID),
|
||||
Title: r.Title,
|
||||
Body: textToPtr(r.Body),
|
||||
Read: r.Read,
|
||||
Archived: r.Archived,
|
||||
CreatedAt: timestampToString(r.CreatedAt),
|
||||
IssueStatus: textToPtr(r.IssueStatus),
|
||||
ActorType: textToPtr(r.ActorType),
|
||||
ActorID: uuidToPtr(r.ActorID),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +100,7 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
resp := make([]InboxItemResponse, len(items))
|
||||
for i, item := range items {
|
||||
resp[i] = inboxToResponse(item)
|
||||
resp[i] = inboxRowToResponse(item)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
|
@ -138,3 +165,91 @@ func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"count": count})
|
||||
}
|
||||
|
||||
func (h *Handler) MarkAllInboxRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.Queries.MarkAllInboxRead(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark all inbox read")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("inbox: mark all read", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
h.publish(protocol.EventInboxBatchRead, workspaceID, "member", userID, map[string]any{
|
||||
"recipient_id": userID,
|
||||
"count": count,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"count": count})
|
||||
}
|
||||
|
||||
func (h *Handler) ArchiveAllInbox(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.Queries.ArchiveAllInbox(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to archive all inbox")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("inbox: archive all", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
|
||||
"recipient_id": userID,
|
||||
"count": count,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"count": count})
|
||||
}
|
||||
|
||||
func (h *Handler) ArchiveAllReadInbox(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.Queries.ArchiveAllReadInbox(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to archive all read inbox")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("inbox: archive all read", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
|
||||
"recipient_id": userID,
|
||||
"count": count,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"count": count})
|
||||
}
|
||||
|
||||
func (h *Handler) ArchiveCompletedInbox(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.Queries.ArchiveCompletedInbox(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to archive completed inbox")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("inbox: archive completed", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
|
||||
"recipient_id": userID,
|
||||
"count": count,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"count": count})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
|
@ -229,11 +231,13 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
Position: 0,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create issue: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := issueToResponse(issue)
|
||||
slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...)
|
||||
h.publish(protocol.EventIssueCreated, workspaceID, "member", creatorID, map[string]any{"issue": resp})
|
||||
|
||||
// Only ready issues in todo are enqueued for agents.
|
||||
|
|
@ -348,11 +352,13 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
issue, err := h.Queries.UpdateIssue(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Warn("update issue failed", append(logger.RequestAttrs(r), "error", err, "issue_id", id, "workspace_id", workspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update issue: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := issueToResponse(issue)
|
||||
slog.Info("issue updated", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", workspaceID)...)
|
||||
|
||||
assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil) &&
|
||||
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
|
||||
|
|
@ -426,5 +432,6 @@ func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
userID := requestUserID(r)
|
||||
h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{"issue_id": id})
|
||||
slog.Info("issue deleted", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", uuidToString(issue.WorkspaceID))...)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ package handler
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
|
@ -158,6 +160,7 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
slog.Info("workspace created", append(logger.RequestAttrs(r), "workspace_id", uuidToString(ws.ID), "name", ws.Name, "slug", ws.Slug)...)
|
||||
writeJSON(w, http.StatusCreated, workspaceToResponse(ws))
|
||||
}
|
||||
|
||||
|
|
@ -204,10 +207,12 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
ws, err := h.Queries.UpdateWorkspace(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Warn("update workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", id)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update workspace: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("workspace updated", append(logger.RequestAttrs(r), "workspace_id", id)...)
|
||||
userID := requestUserID(r)
|
||||
h.publish(protocol.EventWorkspaceUpdated, id, "member", userID, map[string]any{"workspace": workspaceToResponse(ws)})
|
||||
|
||||
|
|
@ -363,10 +368,12 @@ func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusConflict, "user is already a member")
|
||||
return
|
||||
}
|
||||
slog.Warn("create member failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID, "email", email)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create member")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("member added", append(logger.RequestAttrs(r), "member_id", uuidToString(member.ID), "workspace_id", workspaceID, "email", email, "role", role)...)
|
||||
userID := requestUserID(r)
|
||||
h.publish(protocol.EventMemberAdded, workspaceID, "member", userID, map[string]any{"member": memberWithUserResponse(member, user)})
|
||||
|
||||
|
|
@ -479,10 +486,12 @@ func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if err := h.Queries.DeleteMember(r.Context(), target.ID); err != nil {
|
||||
slog.Warn("delete member failed", append(logger.RequestAttrs(r), "error", err, "member_id", memberID, "workspace_id", workspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete member")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("member removed", append(logger.RequestAttrs(r), "member_id", uuidToString(target.ID), "workspace_id", workspaceID, "user_id", uuidToString(target.UserID))...)
|
||||
userID := requestUserID(r)
|
||||
h.publish(protocol.EventMemberRemoved, workspaceID, "member", userID, map[string]any{
|
||||
"member_id": uuidToString(target.ID),
|
||||
|
|
@ -513,10 +522,12 @@ func (h *Handler) LeaveWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if err := h.Queries.DeleteMember(r.Context(), member.ID); err != nil {
|
||||
slog.Warn("leave workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("member removed", append(logger.RequestAttrs(r), "member_id", uuidToString(member.ID), "workspace_id", workspaceID, "user_id", uuidToString(member.UserID))...)
|
||||
userID := requestUserID(r)
|
||||
h.publish(protocol.EventMemberRemoved, workspaceID, "member", userID, map[string]any{
|
||||
"member_id": uuidToString(member.ID),
|
||||
|
|
@ -534,10 +545,12 @@ func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if err := h.Queries.DeleteWorkspace(r.Context(), parseUUID(workspaceID)); err != nil {
|
||||
slog.Warn("delete workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("workspace deleted", append(logger.RequestAttrs(r), "workspace_id", workspaceID)...)
|
||||
h.publish(protocol.EventWorkspaceDeleted, workspaceID, "member", requestUserID(r), map[string]any{
|
||||
"workspace_id": workspaceID,
|
||||
})
|
||||
|
|
|
|||
59
server/internal/logger/logger.go
Normal file
59
server/internal/logger/logger.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/lmittmann/tint"
|
||||
)
|
||||
|
||||
// Init initializes the global slog logger with colored terminal output.
|
||||
// Reads LOG_LEVEL env var (debug, info, warn, error). Default: debug.
|
||||
func Init() {
|
||||
level := parseLevel(os.Getenv("LOG_LEVEL"))
|
||||
handler := tint.NewHandler(os.Stderr, &tint.Options{
|
||||
Level: level,
|
||||
TimeFormat: "15:04:05.000",
|
||||
})
|
||||
slog.SetDefault(slog.New(handler))
|
||||
}
|
||||
|
||||
// NewLogger creates a named slog logger with colored terminal output.
|
||||
// Useful for standalone processes (daemon, migrate) that want a component prefix.
|
||||
func NewLogger(component string) *slog.Logger {
|
||||
level := parseLevel(os.Getenv("LOG_LEVEL"))
|
||||
handler := tint.NewHandler(os.Stderr, &tint.Options{
|
||||
Level: level,
|
||||
TimeFormat: "15:04:05.000",
|
||||
})
|
||||
return slog.New(handler).With("component", component)
|
||||
}
|
||||
|
||||
// RequestAttrs extracts request_id and user_id from an HTTP request
|
||||
// for use in handler-level structured logging.
|
||||
func RequestAttrs(r *http.Request) []any {
|
||||
attrs := make([]any, 0, 4)
|
||||
if rid := chimw.GetReqID(r.Context()); rid != "" {
|
||||
attrs = append(attrs, "request_id", rid)
|
||||
}
|
||||
if uid := r.Header.Get("X-User-ID"); uid != "" {
|
||||
attrs = append(attrs, "user_id", uid)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func parseLevel(s string) slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "info":
|
||||
return slog.LevelInfo
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelDebug
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
|
|
@ -14,12 +15,14 @@ func Auth(next http.Handler) http.Handler {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
slog.Debug("auth: missing authorization header", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
slog.Debug("auth: invalid format", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
|
@ -31,18 +34,21 @@ func Auth(next http.Handler) http.Handler {
|
|||
return auth.JWTSecret(), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
slog.Warn("auth: invalid token", "path", r.URL.Path, "error", err)
|
||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
slog.Warn("auth: invalid claims", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
sub, ok := claims["sub"].(string)
|
||||
if !ok || strings.TrimSpace(sub) == "" {
|
||||
slog.Warn("auth: invalid claims", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
51
server/internal/middleware/request_logger.go
Normal file
51
server/internal/middleware/request_logger.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// RequestLogger is a structured HTTP request logger using slog.
|
||||
// It replaces Chi's built-in chimw.Logger with colored, structured output.
|
||||
func RequestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip noisy endpoints.
|
||||
if r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
status := ww.Status()
|
||||
|
||||
attrs := []any{
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", status,
|
||||
"duration", duration.Round(time.Microsecond).String(),
|
||||
}
|
||||
if rid := chimw.GetReqID(r.Context()); rid != "" {
|
||||
attrs = append(attrs, "request_id", rid)
|
||||
}
|
||||
if uid := r.Header.Get("X-User-ID"); uid != "" {
|
||||
attrs = append(attrs, "user_id", uid)
|
||||
}
|
||||
|
||||
switch {
|
||||
case status >= 500:
|
||||
slog.Error("http request", attrs...)
|
||||
case status >= 400:
|
||||
slog.Warn("http request", attrs...)
|
||||
default:
|
||||
slog.Info("http request", attrs...)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ package realtime
|
|||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -68,7 +68,7 @@ func (h *Hub) Run() {
|
|||
total += len(r)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Printf("Client connected (workspace=%s). Total: %d", room, total)
|
||||
slog.Info("ws client connected", "workspace_id", room, "total_clients", total)
|
||||
|
||||
case client := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
|
|
@ -87,7 +87,7 @@ func (h *Hub) Run() {
|
|||
total += len(r)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Printf("Client disconnected (workspace=%s). Total: %d", room, total)
|
||||
slog.Info("ws client disconnected", "workspace_id", room, "total_clients", total)
|
||||
|
||||
case message := <-h.broadcast:
|
||||
// Global broadcast for daemon events (no workspace filtering)
|
||||
|
|
@ -202,7 +202,7 @@ func HandleWebSocket(hub *Hub, mc MembershipChecker, w http.ResponseWriter, r *h
|
|||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade error: %v", err)
|
||||
slog.Error("websocket upgrade failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -226,15 +226,15 @@ func (c *Client) readPump() {
|
|||
}()
|
||||
|
||||
for {
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
_, _, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
log.Printf("WebSocket read error: %v", err)
|
||||
slog.Debug("websocket read error", "error", err, "user_id", c.userID, "workspace_id", c.workspaceID)
|
||||
}
|
||||
break
|
||||
}
|
||||
// TODO: Route inbound messages to appropriate handlers
|
||||
log.Printf("Received message from user=%s workspace=%s: %s", c.userID, c.workspaceID, message)
|
||||
slog.Debug("ws message received", "user_id", c.userID, "workspace_id", c.workspaceID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -243,7 +243,7 @@ func (c *Client) writePump() {
|
|||
|
||||
for message := range c.send {
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
log.Printf("WebSocket write error: %v", err)
|
||||
slog.Warn("websocket write error", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
|
@ -28,19 +29,23 @@ func NewTaskService(q *db.Queries, hub *realtime.Hub, bus *events.Bus) *TaskServ
|
|||
// EnqueueTaskForIssue creates a task with a context snapshot of the issue.
|
||||
func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) (db.AgentTaskQueue, error) {
|
||||
if !issue.AssigneeID.Valid {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", "issue has no assignee")
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("issue has no assignee")
|
||||
}
|
||||
|
||||
agent, err := s.Queries.GetAgent(ctx, issue.AssigneeID)
|
||||
if err != nil {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err)
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
||||
}
|
||||
if !agent.RuntimeID.Valid {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", "agent has no runtime")
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime")
|
||||
}
|
||||
|
||||
runtime, err := s.Queries.GetAgentRuntime(ctx, agent.RuntimeID)
|
||||
if err != nil {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err)
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("load runtime: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -64,9 +69,11 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) (
|
|||
Context: contextJSON,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err)
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("create task: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(issue.AssigneeID))
|
||||
return task, nil
|
||||
}
|
||||
|
||||
|
|
@ -88,17 +95,21 @@ func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.A
|
|||
return nil, fmt.Errorf("count running tasks: %w", err)
|
||||
}
|
||||
if running >= int64(agent.MaxConcurrentTasks) {
|
||||
slog.Debug("task claim: no capacity", "agent_id", util.UUIDToString(agentID), "running", running, "max", agent.MaxConcurrentTasks)
|
||||
return nil, nil // No capacity
|
||||
}
|
||||
|
||||
task, err := s.Queries.ClaimAgentTask(ctx, agentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
slog.Debug("task claim: no tasks available", "agent_id", util.UUIDToString(agentID))
|
||||
return nil, nil // No tasks available
|
||||
}
|
||||
return nil, fmt.Errorf("claim task: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("task claimed", "task_id", util.UUIDToString(task.ID), "agent_id", util.UUIDToString(agentID))
|
||||
|
||||
// Update agent status to working
|
||||
s.updateAgentStatus(ctx, agentID, "working")
|
||||
|
||||
|
|
@ -143,6 +154,8 @@ func (s *TaskService) StartTask(ctx context.Context, taskID pgtype.UUID) (*db.Ag
|
|||
return nil, fmt.Errorf("start task: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("task started", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID))
|
||||
|
||||
// Sync issue → in_progress
|
||||
issue, err := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
|
||||
ID: task.IssueID,
|
||||
|
|
@ -165,6 +178,8 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
|
|||
return nil, fmt.Errorf("complete task: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("task completed", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID))
|
||||
|
||||
// Sync issue → in_review
|
||||
issue, issueErr := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
|
||||
ID: task.IssueID,
|
||||
|
|
@ -204,6 +219,8 @@ func (s *TaskService) FailTask(ctx context.Context, taskID pgtype.UUID, errMsg s
|
|||
return nil, fmt.Errorf("fail task: %w", err)
|
||||
}
|
||||
|
||||
slog.Warn("task failed", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID), "error", errMsg)
|
||||
|
||||
// Sync issue → blocked
|
||||
issue, issueErr := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
|
||||
ID: task.IssueID,
|
||||
|
|
@ -254,6 +271,7 @@ func (s *TaskService) ReconcileAgentStatus(ctx context.Context, agentID pgtype.U
|
|||
if running > 0 {
|
||||
newStatus = "working"
|
||||
}
|
||||
slog.Debug("agent status reconciled", "agent_id", util.UUIDToString(agentID), "status", newStatus, "running_tasks", running)
|
||||
s.updateAgentStatus(ctx, agentID, newStatus)
|
||||
}
|
||||
|
||||
|
|
|
|||
2
server/migrations/009_inbox_actor.down.sql
Normal file
2
server/migrations/009_inbox_actor.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE inbox_item DROP COLUMN IF EXISTS actor_type;
|
||||
ALTER TABLE inbox_item DROP COLUMN IF EXISTS actor_id;
|
||||
2
server/migrations/009_inbox_actor.up.sql
Normal file
2
server/migrations/009_inbox_actor.up.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE inbox_item ADD COLUMN actor_type TEXT;
|
||||
ALTER TABLE inbox_item ADD COLUMN actor_id UUID;
|
||||
|
|
@ -6,7 +6,7 @@ package agent
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -73,14 +73,14 @@ type Result struct {
|
|||
type Config struct {
|
||||
ExecutablePath string // path to CLI binary (claude or codex)
|
||||
Env map[string]string // extra environment variables
|
||||
Logger *log.Logger
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Backend for the given agent type.
|
||||
// Supported types: "claude", "codex".
|
||||
func New(agentType string, cfg Config) (Backend, error) {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = log.Default()
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
|
||||
switch agentType {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
|
@ -72,7 +72,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
|||
return nil, fmt.Errorf("start claude: %w", err)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Printf("[claude] started pid=%d cwd=%s model=%s", cmd.Process.Pid, opts.Cwd, opts.Model)
|
||||
b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
|
@ -151,8 +151,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
|||
finalError = fmt.Sprintf("claude exited with error: %v", exitErr)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Printf("[claude] finished pid=%d status=%s duration=%s",
|
||||
cmd.Process.Pid, finalStatus, duration.Round(time.Millisecond))
|
||||
b.cfg.Logger.Info("claude finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
||||
|
||||
resCh <- Result{
|
||||
Status: finalStatus,
|
||||
|
|
@ -244,12 +243,12 @@ func (b *claudeBackend) handleControlRequest(msg claudeSDKMessage, stdin interfa
|
|||
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
b.cfg.Logger.Printf("[claude] failed to marshal control response: %v", err)
|
||||
b.cfg.Logger.Warn("claude: failed to marshal control response", "error", err)
|
||||
return
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if _, err := stdin.Write(data); err != nil {
|
||||
b.cfg.Logger.Printf("[claude] failed to write control response: %v", err)
|
||||
b.cfg.Logger.Warn("claude: failed to write control response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,20 +328,20 @@ func detectCLIVersion(ctx context.Context, execPath string) (string, error) {
|
|||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// logWriter adapts a *log.Logger to an io.Writer for capturing stderr.
|
||||
// logWriter adapts a *slog.Logger to an io.Writer for capturing stderr.
|
||||
type logWriter struct {
|
||||
logger *log.Logger
|
||||
logger *slog.Logger
|
||||
prefix string
|
||||
}
|
||||
|
||||
func newLogWriter(logger *log.Logger, prefix string) *logWriter {
|
||||
func newLogWriter(logger *slog.Logger, prefix string) *logWriter {
|
||||
return &logWriter{logger: logger, prefix: prefix}
|
||||
}
|
||||
|
||||
func (w *logWriter) Write(p []byte) (int, error) {
|
||||
text := strings.TrimSpace(string(p))
|
||||
if text != "" {
|
||||
w.logger.Printf("%s%s", w.prefix, text)
|
||||
w.logger.Debug(w.prefix + text)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package agent
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
|
@ -11,7 +11,7 @@ import (
|
|||
func TestClaudeHandleAssistantText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
||||
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ func TestClaudeHandleAssistantText(t *testing.T) {
|
|||
func TestClaudeHandleAssistantToolUse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
||||
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
|
|
@ -83,7 +83,7 @@ func TestClaudeHandleAssistantToolUse(t *testing.T) {
|
|||
func TestClaudeHandleUserToolResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
||||
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
msg := claudeSDKMessage{
|
||||
|
|
@ -115,8 +115,7 @@ func TestClaudeHandleUserToolResult(t *testing.T) {
|
|||
func TestClaudeHandleControlRequestAutoApproves(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
b := &claudeBackend{cfg: Config{Logger: log.New(&buf, "", 0)}}
|
||||
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
|
||||
var written bytes.Buffer
|
||||
|
||||
|
|
@ -153,7 +152,7 @@ func TestClaudeHandleControlRequestAutoApproves(t *testing.T) {
|
|||
func TestClaudeHandleAssistantInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
||||
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
|
|||
return nil, fmt.Errorf("start codex: %w", err)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Printf("[codex] started app-server pid=%d cwd=%s", cmd.Process.Pid, opts.Cwd)
|
||||
b.cfg.Logger.Info("codex started app-server", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
|
@ -171,7 +171,7 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
|
|||
return
|
||||
}
|
||||
c.threadID = threadID
|
||||
b.cfg.Logger.Printf("[codex] thread started: %s", threadID)
|
||||
b.cfg.Logger.Info("codex thread started", "thread_id", threadID)
|
||||
|
||||
// 3. Send turn and wait for completion
|
||||
_, err = c.request(runCtx, "turn/start", map[string]any{
|
||||
|
|
@ -205,8 +205,7 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
|
|||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
b.cfg.Logger.Printf("[codex] finished pid=%d status=%s duration=%s",
|
||||
cmd.Process.Pid, finalStatus, duration.Round(time.Millisecond))
|
||||
b.cfg.Logger.Info("codex finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
||||
|
||||
// Close stdin and cancel context to signal the app-server to exit.
|
||||
// Without this, the long-running codex process keeps stdout open and
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package agent
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
|
@ -15,7 +15,7 @@ func newTestCodexClient(t *testing.T) (*codexClient, *fakeStdin, []Message) {
|
|||
var messages []Message
|
||||
|
||||
c := &codexClient{
|
||||
cfg: Config{Logger: log.Default()},
|
||||
cfg: Config{Logger: slog.Default()},
|
||||
stdin: fs,
|
||||
pending: make(map[int]*pendingRPC),
|
||||
onMessage: func(msg Message) {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,50 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const archiveAllInbox = `-- name: ArchiveAllInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false
|
||||
`
|
||||
|
||||
func (q *Queries) ArchiveAllInbox(ctx context.Context, recipientID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, archiveAllInbox, recipientID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const archiveAllReadInbox = `-- name: ArchiveAllReadInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND read = true AND archived = false
|
||||
`
|
||||
|
||||
func (q *Queries) ArchiveAllReadInbox(ctx context.Context, recipientID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, archiveAllReadInbox, recipientID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const archiveCompletedInbox = `-- name: ArchiveCompletedInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false
|
||||
AND issue_id IN (SELECT id FROM issue WHERE status IN ('done', 'cancelled'))
|
||||
`
|
||||
|
||||
func (q *Queries) ArchiveCompletedInbox(ctx context.Context, recipientID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, archiveCompletedInbox, recipientID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const archiveInboxItem = `-- name: ArchiveInboxItem :one
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id
|
||||
`
|
||||
|
||||
func (q *Queries) ArchiveInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem, error) {
|
||||
|
|
@ -33,6 +73,8 @@ func (q *Queries) ArchiveInboxItem(ctx context.Context, id pgtype.UUID) (InboxIt
|
|||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -57,9 +99,10 @@ func (q *Queries) CountUnreadInbox(ctx context.Context, arg CountUnreadInboxPara
|
|||
const createInboxItem = `-- name: CreateInboxItem :one
|
||||
INSERT INTO inbox_item (
|
||||
workspace_id, recipient_type, recipient_id,
|
||||
type, severity, issue_id, title, body
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
|
||||
type, severity, issue_id, title, body,
|
||||
actor_type, actor_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id
|
||||
`
|
||||
|
||||
type CreateInboxItemParams struct {
|
||||
|
|
@ -71,6 +114,8 @@ type CreateInboxItemParams struct {
|
|||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body pgtype.Text `json:"body"`
|
||||
ActorType pgtype.Text `json:"actor_type"`
|
||||
ActorID pgtype.UUID `json:"actor_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams) (InboxItem, error) {
|
||||
|
|
@ -83,6 +128,8 @@ func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams
|
|||
arg.IssueID,
|
||||
arg.Title,
|
||||
arg.Body,
|
||||
arg.ActorType,
|
||||
arg.ActorID,
|
||||
)
|
||||
var i InboxItem
|
||||
err := row.Scan(
|
||||
|
|
@ -98,12 +145,14 @@ func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams
|
|||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getInboxItem = `-- name: GetInboxItem :one
|
||||
SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at FROM inbox_item
|
||||
SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id FROM inbox_item
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
|
|
@ -123,14 +172,19 @@ func (q *Queries) GetInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem,
|
|||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listInboxItems = `-- name: ListInboxItems :many
|
||||
SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND archived = false
|
||||
ORDER BY created_at DESC
|
||||
SELECT i.id, i.workspace_id, i.recipient_type, i.recipient_id, i.type, i.severity, i.issue_id, i.title, i.body, i.read, i.archived, i.created_at, i.actor_type, i.actor_id,
|
||||
iss.status as issue_status
|
||||
FROM inbox_item i
|
||||
LEFT JOIN issue iss ON iss.id = i.issue_id
|
||||
WHERE i.recipient_type = $1 AND i.recipient_id = $2 AND i.archived = false
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
|
|
@ -141,7 +195,25 @@ type ListInboxItemsParams struct {
|
|||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) ([]InboxItem, error) {
|
||||
type ListInboxItemsRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body pgtype.Text `json:"body"`
|
||||
Read bool `json:"read"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
ActorType pgtype.Text `json:"actor_type"`
|
||||
ActorID pgtype.UUID `json:"actor_id"`
|
||||
IssueStatus pgtype.Text `json:"issue_status"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) ([]ListInboxItemsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listInboxItems,
|
||||
arg.RecipientType,
|
||||
arg.RecipientID,
|
||||
|
|
@ -152,9 +224,9 @@ func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams)
|
|||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []InboxItem{}
|
||||
items := []ListInboxItemsRow{}
|
||||
for rows.Next() {
|
||||
var i InboxItem
|
||||
var i ListInboxItemsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
|
|
@ -168,6 +240,9 @@ func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams)
|
|||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
&i.IssueStatus,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -179,10 +254,23 @@ func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams)
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const markAllInboxRead = `-- name: MarkAllInboxRead :execrows
|
||||
UPDATE inbox_item SET read = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false AND read = false
|
||||
`
|
||||
|
||||
func (q *Queries) MarkAllInboxRead(ctx context.Context, recipientID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, markAllInboxRead, recipientID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const markInboxRead = `-- name: MarkInboxRead :one
|
||||
UPDATE inbox_item SET read = true
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id
|
||||
`
|
||||
|
||||
func (q *Queries) MarkInboxRead(ctx context.Context, id pgtype.UUID) (InboxItem, error) {
|
||||
|
|
@ -201,6 +289,8 @@ func (q *Queries) MarkInboxRead(ctx context.Context, id pgtype.UUID) (InboxItem,
|
|||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ type InboxItem struct {
|
|||
Read bool `json:"read"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
ActorType pgtype.Text `json:"actor_type"`
|
||||
ActorID pgtype.UUID `json:"actor_id"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
-- name: ListInboxItems :many
|
||||
SELECT * FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND archived = false
|
||||
ORDER BY created_at DESC
|
||||
SELECT i.*,
|
||||
iss.status as issue_status
|
||||
FROM inbox_item i
|
||||
LEFT JOIN issue iss ON iss.id = i.issue_id
|
||||
WHERE i.recipient_type = $1 AND i.recipient_id = $2 AND i.archived = false
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetInboxItem :one
|
||||
|
|
@ -11,8 +14,9 @@ WHERE id = $1;
|
|||
-- name: CreateInboxItem :one
|
||||
INSERT INTO inbox_item (
|
||||
workspace_id, recipient_type, recipient_id,
|
||||
type, severity, issue_id, title, body
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
type, severity, issue_id, title, body,
|
||||
actor_type, actor_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *;
|
||||
|
||||
-- name: MarkInboxRead :one
|
||||
|
|
@ -28,3 +32,20 @@ RETURNING *;
|
|||
-- name: CountUnreadInbox :one
|
||||
SELECT count(*) FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND read = false AND archived = false;
|
||||
|
||||
-- name: MarkAllInboxRead :execrows
|
||||
UPDATE inbox_item SET read = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false AND read = false;
|
||||
|
||||
-- name: ArchiveAllInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false;
|
||||
|
||||
-- name: ArchiveAllReadInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND read = true AND archived = false;
|
||||
|
||||
-- name: ArchiveCompletedInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false
|
||||
AND issue_id IN (SELECT id FROM issue WHERE status IN ('done', 'cancelled'));
|
||||
|
|
|
|||
|
|
@ -24,9 +24,11 @@ const (
|
|||
EventTaskFailed = "task:failed"
|
||||
|
||||
// Inbox events
|
||||
EventInboxNew = "inbox:new"
|
||||
EventInboxRead = "inbox:read"
|
||||
EventInboxArchived = "inbox:archived"
|
||||
EventInboxNew = "inbox:new"
|
||||
EventInboxRead = "inbox:read"
|
||||
EventInboxArchived = "inbox:archived"
|
||||
EventInboxBatchRead = "inbox:batch-read"
|
||||
EventInboxBatchArchived = "inbox:batch-archived"
|
||||
|
||||
// Workspace events
|
||||
EventWorkspaceUpdated = "workspace:updated"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue