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