feat(page): Enhance agent management UI with detailed status and sorting
This commit is contained in:
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { getApiBaseUrl } from "@/lib/api-base";
|
import { getApiBaseUrl } from "@/lib/api-base";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Board = {
|
type Board = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -47,6 +49,7 @@ type Task = {
|
|||||||
type Agent = {
|
type Agent = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
status: string;
|
||||||
board_id?: string | null;
|
board_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,7 +85,7 @@ export default function BoardDetailPage() {
|
|||||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||||
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
||||||
const [commentsError, setCommentsError] = useState<string | null>(null);
|
const [commentsError, setCommentsError] = useState<string | null>(null);
|
||||||
const [isCommentsOpen, setIsCommentsOpen] = useState(false);
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -216,6 +219,35 @@ export default function BoardDetailPage() {
|
|||||||
[tasks, assigneeById],
|
[tasks, assigneeById],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const boardAgents = useMemo(
|
||||||
|
() => agents.filter((agent) => !boardId || agent.board_id === boardId),
|
||||||
|
[agents, boardId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const workingAgentIds = useMemo(() => {
|
||||||
|
const working = new Set<string>();
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (task.status === "in_progress" && task.assigned_agent_id) {
|
||||||
|
working.add(task.assigned_agent_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return working;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
const sortedAgents = useMemo(() => {
|
||||||
|
const rank = (agent: Agent) => {
|
||||||
|
if (workingAgentIds.has(agent.id)) return 0;
|
||||||
|
if (agent.status === "online") return 1;
|
||||||
|
if (agent.status === "provisioning") return 2;
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
return [...boardAgents].sort((a, b) => {
|
||||||
|
const diff = rank(a) - rank(b);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}, [boardAgents, workingAgentIds]);
|
||||||
|
|
||||||
const loadComments = async (taskId: string) => {
|
const loadComments = async (taskId: string) => {
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
setIsCommentsLoading(true);
|
setIsCommentsLoading(true);
|
||||||
@@ -242,17 +274,33 @@ export default function BoardDetailPage() {
|
|||||||
|
|
||||||
const openComments = (task: Task) => {
|
const openComments = (task: Task) => {
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
setIsCommentsOpen(true);
|
setIsDetailOpen(true);
|
||||||
void loadComments(task.id);
|
void loadComments(task.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeComments = () => {
|
const closeComments = () => {
|
||||||
setIsCommentsOpen(false);
|
setIsDetailOpen(false);
|
||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
setComments([]);
|
setComments([]);
|
||||||
setCommentsError(null);
|
setCommentsError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const agentInitials = (name: string) =>
|
||||||
|
name
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
const agentStatusLabel = (agent: Agent) => {
|
||||||
|
if (workingAgentIds.has(agent.id)) return "Working";
|
||||||
|
if (agent.status === "online") return "Active";
|
||||||
|
if (agent.status === "provisioning") return "Provisioning";
|
||||||
|
return "Offline";
|
||||||
|
};
|
||||||
|
|
||||||
const formatCommentTimestamp = (value: string) => {
|
const formatCommentTimestamp = (value: string) => {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return "—";
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
@@ -318,7 +366,69 @@ export default function BoardDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="relative flex gap-6 p-6">
|
||||||
|
<aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Agents
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{sortedAgents.length} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/agents/new")}
|
||||||
|
className="rounded-md border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||||
|
{sortedAgents.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
|
||||||
|
No agents assigned yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedAgents.map((agent) => {
|
||||||
|
const isWorking = workingAgentIds.has(agent.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg border border-transparent px-2 py-2 transition hover:border-slate-200 hover:bg-slate-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
|
||||||
|
{agentInitials(agent.name)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
|
||||||
|
isWorking
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: agent.status === "online"
|
||||||
|
? "bg-green-500"
|
||||||
|
: "bg-slate-300",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
|
{agent.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
{agentStatusLabel(agent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
<div className="mb-4 rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
||||||
{error}
|
{error}
|
||||||
@@ -338,42 +448,65 @@ export default function BoardDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
{isDetailOpen ? (
|
||||||
<Dialog open={isCommentsOpen} onOpenChange={(open) => {
|
<div className="fixed inset-0 z-40 bg-slate-900/20" onClick={closeComments} />
|
||||||
if (!open) {
|
) : null}
|
||||||
closeComments();
|
<aside
|
||||||
}
|
className={cn(
|
||||||
}}>
|
"fixed right-0 top-0 z-50 h-full w-[420px] max-w-[92vw] transform bg-white shadow-2xl transition-transform",
|
||||||
<DialogContent aria-label="Task comments">
|
isDetailOpen ? "translate-x-0" : "translate-x-full",
|
||||||
<DialogHeader>
|
)}
|
||||||
<DialogTitle>{selectedTask?.title ?? "Task"}</DialogTitle>
|
>
|
||||||
<DialogDescription>
|
<div className="flex h-full flex-col">
|
||||||
{selectedTask?.description || "Task details and discussion."}
|
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||||
</DialogDescription>
|
<div>
|
||||||
</DialogHeader>
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
<div className="space-y-4">
|
Task detail
|
||||||
<div className="space-y-3">
|
</p>
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||||
Comments
|
{selectedTask?.title ?? "Task"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeComments}
|
||||||
|
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Description
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
{selectedTask?.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Comments
|
||||||
|
</p>
|
||||||
{isCommentsLoading ? (
|
{isCommentsLoading ? (
|
||||||
<p className="text-sm text-muted">Loading comments…</p>
|
<p className="text-sm text-slate-500">Loading comments…</p>
|
||||||
) : commentsError ? (
|
) : commentsError ? (
|
||||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-500">
|
||||||
{commentsError}
|
{commentsError}
|
||||||
</div>
|
</div>
|
||||||
) : comments.length === 0 ? (
|
) : comments.length === 0 ? (
|
||||||
<p className="text-sm text-muted">No comments yet.</p>
|
<p className="text-sm text-slate-500">No comments yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => (
|
||||||
<div
|
<div
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-3"
|
className="rounded-xl border border-slate-200 bg-white p-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between text-xs text-muted">
|
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||||
<span>
|
<span>
|
||||||
{comment.agent_id
|
{comment.agent_id
|
||||||
? assigneeById.get(comment.agent_id) ?? "Agent"
|
? assigneeById.get(comment.agent_id) ?? "Agent"
|
||||||
@@ -381,7 +514,7 @@ export default function BoardDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span>{formatCommentTimestamp(comment.created_at)}</span>
|
<span>{formatCommentTimestamp(comment.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-strong">
|
<p className="mt-2 text-sm text-slate-900">
|
||||||
{comment.message || "—"}
|
{comment.message || "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -390,13 +523,8 @@ export default function BoardDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
</div>
|
||||||
<Button variant="outline" onClick={closeComments}>
|
</aside>
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user