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:
Naiyuan Qing 2026-03-26 12:40:56 +08:00 committed by GitHub
commit a997bcfec0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 2742 additions and 1751 deletions

View file

@ -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);

View file

@ -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>
);
}

View file

@ -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" />
) : (

View file

@ -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>

View file

@ -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">&bull;</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} />;
}

View file

@ -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();
});
});

View file

@ -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 />;
}

View file

@ -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>
)}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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 (

View file

@ -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>
),

View file

@ -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}</>;

View file

@ -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,
}));

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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";

View 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">&bull;</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>

View file

@ -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>
);

View file

@ -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`}
>

View file

@ -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";

View file

@ -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 });
}
},

View 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,
}),
}
)
);

View file

@ -0,0 +1 @@
export { useNavigationStore } from "./store";

View 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 }),
}
)
);

View file

@ -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);

View file

@ -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(),

View file

@ -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>
)}

View file

@ -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;

View file

@ -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;

View file

@ -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
View 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() {},
};

View file

@ -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");

View file

@ -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";

View 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() {},
};

View file

@ -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);
};

View file

@ -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;
}

View file

@ -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;

View file

@ -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)

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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),
}
}

View file

@ -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 != "" {

View file

@ -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")
}

View file

@ -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)
})

View file

@ -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

View file

@ -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=

View file

@ -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)
}
}
}()

View file

@ -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

View file

@ -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) {

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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),

View file

@ -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),

View file

@ -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))
}

View file

@ -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})
}

View file

@ -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)
}

View file

@ -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,
})

View 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
}
}

View file

@ -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
}

View 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...)
}
})
}

View file

@ -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
}
}

View file

@ -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)
}

View 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;

View file

@ -0,0 +1,2 @@
ALTER TABLE inbox_item ADD COLUMN actor_type TEXT;
ALTER TABLE inbox_item ADD COLUMN actor_id UUID;

View file

@ -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 {

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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
}

View file

@ -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 {

View file

@ -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'));

View file

@ -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"