diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 6a748e99..b1c2cee2 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; +import { useNavigationStore } from "@/features/navigation"; import { api } from "@/shared/api"; import { Card, @@ -40,7 +41,8 @@ function LoginPageContent() { await login(email, name || undefined); const wsList = await api.listWorkspaces(); await hydrateWorkspace(wsList); - router.push(searchParams.get("next") || "/issues"); + const fallback = useNavigationStore.getState().lastPath; + router.push(searchParams.get("next") || fallback); } catch (err) { setError("Login failed. Make sure the server is running."); setSubmitting(false); diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index 1b04cf31..c3caca71 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -20,7 +20,6 @@ import { WorkspaceAvatar } from "@/features/workspace"; import { Sidebar, SidebarContent, - SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, @@ -43,11 +42,14 @@ import { useWorkspaceStore } from "@/features/workspace"; import { useInboxStore } from "@/features/inbox"; import { useModalStore } from "@/features/modals"; -const navItems = [ +const primaryNav = [ { href: "/inbox", label: "Inbox", icon: Inbox }, + { href: "/issues", label: "Issues", icon: ListTodo }, +]; + +const workspaceNav = [ { href: "/agents", label: "Agents", icon: Bot }, { href: "/skills", label: "Skills", icon: Sparkles }, - { href: "/issues", label: "Issues", icon: ListTodo }, { href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen }, ]; @@ -73,7 +75,7 @@ export function AppSidebar() { return ( {/* Workspace Switcher */} - +
@@ -180,10 +182,8 @@ export function AppSidebar() { - {navItems.map((item) => { - const isActive = - pathname === item.href || - pathname.startsWith(item.href + "/"); + {primaryNav.map((item) => { + const isActive = pathname === item.href; return ( {item.label} {item.label === "Inbox" && unreadCount > 0 && ( - + {unreadCount > 99 ? "99+" : unreadCount} )} @@ -205,28 +205,29 @@ export function AppSidebar() { - - {/* User */} - - {user && ( - - - -
- {user.name - .split(" ") - .map((w) => w[0]) - .join("") - .toUpperCase() - .slice(0, 2)} -
- {user.name} -
-
-
- )} -
+ + + + {workspaceNav.map((item) => { + const isActive = pathname === item.href; + return ( + + } + className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground" + > + + {item.label} + + + ); + })} + + + + ); } diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index b786b238..d27e3ee4 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -186,7 +186,7 @@ function CreateAgentDialog({ {selectedRuntime?.name ?? "No runtime available"} {selectedRuntime?.runtime_mode === "cloud" && ( - + Cloud )} @@ -222,7 +222,7 @@ function CreateAgentDialog({
{device.name} {device.runtime_mode === "cloud" && ( - + Cloud )} @@ -812,7 +812,7 @@ function TriggersTab({ > @@ -1007,7 +1007,7 @@ function AgentDetail({ {st.label} - + {agent.runtime_mode === "cloud" ? ( ) : ( diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 3a8bfc92..32440798 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -1,21 +1,28 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import { useInboxStore } from "@/features/inbox"; +import { IssueDetail, StatusIcon } from "@/features/issues/components"; +import { ActorAvatar } from "@/components/common/actor-avatar"; import { toast } from "sonner"; import { - AlertCircle, - Bot, - CheckCircle2, - CircleDot, - GitPullRequest, - MessageSquare, - ArrowRightLeft, + MoreHorizontal, + Inbox, + CheckCheck, + Archive, + BookCheck, + ListChecks, } from "lucide-react"; import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types"; -import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; import { api } from "@/shared/api"; // --------------------------------------------------------------------------- @@ -28,33 +35,28 @@ const severityOrder: Record = { info: 2, }; -const typeIcons: Record = { - agent_blocked: AlertCircle, - review_requested: GitPullRequest, - issue_assigned: CircleDot, - agent_completed: CheckCircle2, - mentioned: MessageSquare, - status_change: ArrowRightLeft, -}; - -const severityColors: Record = { - action_required: "text-destructive", - attention: "text-warning", - info: "text-muted-foreground", +const typeLabels: Record = { + issue_assigned: "Assigned", + review_requested: "Review requested", + agent_blocked: "Agent blocked", + agent_completed: "Agent completed", + mentioned: "Mentioned", + status_change: "Status changed", }; function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const minutes = Math.floor(diff / 60000); - if (minutes < 60) return `${minutes}m ago`; + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; + if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); - return `${days}d ago`; + return `${days}d`; } // --------------------------------------------------------------------------- -// Components +// InboxListItem // --------------------------------------------------------------------------- function InboxListItem({ @@ -66,107 +68,47 @@ function InboxListItem({ isSelected: boolean; onClick: () => void; }) { - const Icon = typeIcons[item.type] ?? CircleDot; - const colorClass = severityColors[item.severity]; - return ( ); } -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 = { - action_required: "Action required", - attention: "Needs attention", - info: "Info", - }; - - return ( -
- {/* Header */} -
- -
-

{item.title}

-
- {severityLabel[item.severity]} - · - {timeAgo(item.created_at)} -
-
- {!item.read && ( - - )} - {item.issue_id && ( - - View Issue - - )} - -
- - {/* Body */} - {item.body && ( -
- {item.body} -
- )} -
- ); -} - // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -174,7 +116,6 @@ function InboxDetail({ export default function InboxPage() { const [selectedId, setSelectedId] = useState(""); - // Read from global store (populated by workspace hydrate + useRealtimeSync) const storeItems = useInboxStore((s) => s.items); const loading = useInboxStore((s) => s.loading); @@ -189,19 +130,19 @@ export default function InboxPage() { ); }, [storeItems]); - // Auto-select first item when items change - useEffect(() => { - if (items.length > 0 && !selectedId) { - setSelectedId(items[0]!.id); - } - }, [items, selectedId]); + const selected = items.find((i) => i.id === selectedId) ?? null; + const unreadCount = items.filter((i) => !i.read).length; - const handleMarkRead = async (id: string) => { - try { - await api.markInboxRead(id); - useInboxStore.getState().markRead(id); - } catch (err) { - toast.error("Failed to mark as read"); + // Click-to-read: select + auto-mark-read + const handleSelect = async (item: InboxItem) => { + setSelectedId(item.id); + if (!item.read) { + try { + await api.markInboxRead(item.id); + useInboxStore.getState().markRead(item.id); + } catch { + // silent — selection still works even if mark-read fails + } } }; @@ -209,17 +150,55 @@ export default function InboxPage() { try { await api.archiveInbox(id); useInboxStore.getState().archive(id); - // If archived item was selected, clear selection - if (selectedId === id) { - setSelectedId(""); - } - } catch (err) { + if (selectedId === id) setSelectedId(""); + } catch { toast.error("Failed to archive"); } }; - const selected = items.find((i) => i.id === selectedId) ?? null; - const unreadCount = items.filter((i) => !i.read).length; + // Batch operations + const handleMarkAllRead = async () => { + try { + useInboxStore.getState().markAllRead(); + await api.markAllInboxRead(); + } catch { + toast.error("Failed to mark all as read"); + useInboxStore.getState().fetch(); + } + }; + + const handleArchiveAll = async () => { + try { + useInboxStore.getState().archiveAll(); + setSelectedId(""); + await api.archiveAllInbox(); + } catch { + toast.error("Failed to archive all"); + useInboxStore.getState().fetch(); + } + }; + + const handleArchiveAllRead = async () => { + try { + const readIds = items.filter((i) => i.read).map((i) => i.id); + useInboxStore.getState().archiveAllRead(); + if (readIds.includes(selectedId)) setSelectedId(""); + await api.archiveAllReadInbox(); + } catch { + toast.error("Failed to archive read items"); + useInboxStore.getState().fetch(); + } + }; + + const handleArchiveCompleted = async () => { + try { + await api.archiveCompletedInbox(); + setSelectedId(""); + await useInboxStore.getState().fetch(); + } catch { + toast.error("Failed to archive completed"); + } + }; if (loading) { return ( @@ -230,8 +209,8 @@ export default function InboxPage() {
{Array.from({ length: 5 }).map((_, i) => ( -
- +
+
@@ -243,7 +222,6 @@ export default function InboxPage() {
-
); @@ -253,17 +231,53 @@ export default function InboxPage() {
{/* Left column — inbox list */}
-
-

Inbox

- {unreadCount > 0 && ( - - {unreadCount} - - )} +
+
+

Inbox

+ {unreadCount > 0 && ( + + {unreadCount} + + )} +
+ + + } + > + + + + + + Mark all as read + + + + + Archive all + + + + Archive all read + + + + Archive completed + + +
+ {items.length === 0 ? ( -
-

No notifications yet

+
+ +

No notifications

) : (
@@ -272,7 +286,7 @@ export default function InboxPage() { key={item.id} item={item} isSelected={item.id === selectedId} - onClick={() => setSelectedId(item.id)} + onClick={() => handleSelect(item)} /> ))}
@@ -280,14 +294,45 @@ export default function InboxPage() {
{/* Right column — detail */} -
- {selected ? ( - +
+ {selected?.issue_id ? ( + { + handleArchive(selected.id); + }} + /> + ) : selected ? ( +
+

{selected.title}

+

+ {typeLabels[selected.type]} · {timeAgo(selected.created_at)} +

+ {selected.body && ( +
+ {selected.body} +
+ )} +
+ +
+
) : ( -
- {items.length === 0 - ? "Your inbox is empty" - : "Select an item to view details"} +
+ +

+ {items.length === 0 + ? "Your inbox is empty" + : "Select a notification to view details"} +

)}
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index 601bbf64..210e24be 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -1,312 +1,7 @@ "use client"; -import { use, useState, useEffect, useCallback } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { - ChevronRight, - Link2, - Pencil, - Send, - Trash2, - X, -} from "lucide-react"; -import { toast } from "sonner"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Calendar } from "@/components/ui/calendar"; -import { - Popover, - PopoverTrigger, - PopoverContent, -} from "@/components/ui/popover"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@/components/ui/tooltip"; -import { ActorAvatar } from "@/components/common/actor-avatar"; -import type { Issue, Comment, UpdateIssueRequest } from "@multica/types"; -import { StatusPicker, PriorityPicker, AssigneePicker } from "@/features/issues/components"; -import { api } from "@/shared/api"; -import { useAuthStore } from "@/features/auth"; -import { useActorName } from "@/features/workspace"; -import { useWSEvent } from "@/features/realtime"; -import { useIssueStore } from "@/features/issues"; -import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function timeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - -function shortDate(date: string | null): string { - if (!date) return "—"; - return new Date(date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); -} - -// --------------------------------------------------------------------------- -// Property row -// --------------------------------------------------------------------------- - -function PropRow({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
- {label} -
- {children} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Due Date Picker -// --------------------------------------------------------------------------- - -function DueDatePicker({ - dueDate, - onUpdate, -}: { - dueDate: string | null; - onUpdate: (updates: Partial) => void; -}) { - const [open, setOpen] = useState(false); - const date = dueDate ? new Date(dueDate) : undefined; - const isOverdue = date ? date < new Date() : false; - - return ( - - - {date ? ( - - {date.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - - ) : ( - None - )} - - - { - onUpdate({ due_date: d ? d.toISOString() : null }); - setOpen(false); - }} - /> - {date && ( -
- -
- )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Acceptance Criteria Editor -// --------------------------------------------------------------------------- - -function AcceptanceCriteriaEditor({ - criteria, - onUpdate, -}: { - criteria: string[]; - onUpdate: (updates: Partial) => 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 ( -
-

Acceptance Criteria

- {criteria.length > 0 && ( -
- {criteria.map((item, i) => ( -
- - {item} - -
- ))} -
- )} - {(criteria.length > 0 || adding) ? ( -
{ e.preventDefault(); addItem(); }} - className="flex items-center gap-2" - > - 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" - /> -
- ) : ( - - )} -
- ); -} - -// --------------------------------------------------------------------------- -// Context Refs Editor -// --------------------------------------------------------------------------- - -function ContextRefsEditor({ - refs, - onUpdate, -}: { - refs: string[]; - onUpdate: (updates: Partial) => 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 ( -
-

Context References

- {refs.length > 0 && ( -
- {refs.map((ref, i) => ( -
- - {isUrl(ref) ? ( - - {ref} - - ) : ( - {ref} - )} - -
- ))} -
- )} - {(refs.length > 0 || adding) ? ( -
{ e.preventDefault(); addRef(); }} - className="flex items-center gap-2" - > - 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" - /> -
- ) : ( - - )} -
- ); -} - -// --------------------------------------------------------------------------- -// Page -// --------------------------------------------------------------------------- +import { use } from "react"; +import { IssueDetail } from "@/features/issues/components"; export default function IssueDetailPage({ params, @@ -314,437 +9,5 @@ export default function IssueDetailPage({ params: Promise<{ id: string }>; }) { const { id } = use(params); - const router = useRouter(); - const user = useAuthStore((s) => s.user); - const { getActorName, getActorInitials } = useActorName(); - const [issue, setIssue] = useState(null); - const [comments, setComments] = useState([]); - const [loading, setLoading] = useState(true); - const [commentText, setCommentText] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [deleting, setDeleting] = useState(false); - const [editingCommentId, setEditingCommentId] = useState(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) => { - 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 ( -
- Loading... -
- ); - } - - if (!issue) { - return ( -
- Issue not found -
- ); - } - - return ( -
- {/* LEFT: Content area */} -
- {/* Header bar */} -
-
- - Issues - - - {issue.id.slice(0, 8)} -
- - } - > - - - - - Delete issue - - This will permanently delete this issue and all its comments. This action cannot be undone. - - - - Cancel - - {deleting ? "Deleting..." : "Delete"} - - - - -
- - {/* Content */} -
-
{issue.id.slice(0, 8)}
- - {editingTitle ? ( - 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" - /> - ) : ( -

{ setTitleDraft(issue.title); setEditingTitle(true); }} - > - {issue.title} -

- )} - - {editingDesc ? ( -