feat: implement task creation endpoint for board leads and enhance board chat functionality

This commit is contained in:
Abhimanyu Saharan
2026-02-06 00:44:03 +05:30
parent f8f5849341
commit 69a6597936
10 changed files with 911 additions and 197 deletions

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { Pencil, Settings, X } from "lucide-react";
import { MessageSquare, Pencil, Settings, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
@@ -53,6 +53,8 @@ type Task = {
assigned_agent_id?: string | null;
created_at?: string | null;
updated_at?: string | null;
approvalsCount?: number;
approvalsPendingCount?: number;
};
type Agent = {
@@ -87,8 +89,25 @@ type Approval = {
resolved_at?: string | null;
};
type BoardChatMessage = {
id: string;
content: string;
tags?: string[] | null;
source?: string | null;
created_at: string;
};
const apiBase = getApiBaseUrl();
const approvalTaskId = (approval: Approval) => {
const payload = approval.payload ?? {};
return (
(payload as Record<string, unknown>).task_id ??
(payload as Record<string, unknown>).taskId ??
(payload as Record<string, unknown>).taskID
);
};
const priorities = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
@@ -147,6 +166,12 @@ export default function BoardDetailPage() {
const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>(
null,
);
const [isChatOpen, setIsChatOpen] = useState(false);
const [chatMessages, setChatMessages] = useState<BoardChatMessage[]>([]);
const [chatInput, setChatInput] = useState("");
const [isChatSending, setIsChatSending] = useState(false);
const [chatError, setChatError] = useState<string | null>(null);
const chatMessagesRef = useRef<BoardChatMessage[]>([]);
const [isDeletingTask, setIsDeletingTask] = useState(false);
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board");
@@ -274,6 +299,10 @@ export default function BoardDetailPage() {
agentsRef.current = agents;
}, [agents]);
useEffect(() => {
chatMessagesRef.current = chatMessages;
}, [chatMessages]);
const loadApprovals = useCallback(async () => {
if (!isSignedIn || !boardId) return;
setIsApprovalsLoading(true);
@@ -306,6 +335,138 @@ export default function BoardDetailPage() {
loadApprovals();
}, [boardId, isSignedIn, loadApprovals]);
const loadBoardChat = useCallback(async () => {
if (!isSignedIn || !boardId) return;
setChatError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/memory?limit=200`,
{
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
},
);
if (!response.ok) {
throw new Error("Unable to load board chat.");
}
const data = (await response.json()) as BoardChatMessage[];
const chatOnly = data.filter((item) => item.tags?.includes("chat"));
const ordered = chatOnly.sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return aTime - bTime;
});
setChatMessages(ordered);
} catch (err) {
setChatError(
err instanceof Error ? err.message : "Unable to load board chat.",
);
}
}, [boardId, getToken, isSignedIn]);
useEffect(() => {
loadBoardChat();
}, [boardId, isSignedIn, loadBoardChat]);
const latestChatTimestamp = (items: BoardChatMessage[]) => {
if (!items.length) return undefined;
const latest = items.reduce((max, item) => {
const ts = new Date(item.created_at).getTime();
return Number.isNaN(ts) ? max : Math.max(max, ts);
}, 0);
if (!latest) return undefined;
return new Date(latest).toISOString();
};
useEffect(() => {
if (!isSignedIn || !boardId) return;
let isCancelled = false;
const abortController = new AbortController();
const connect = async () => {
try {
const token = await getToken();
if (!token || isCancelled) return;
const url = new URL(
`${apiBase}/api/v1/boards/${boardId}/memory/stream`,
);
const since = latestChatTimestamp(chatMessagesRef.current);
if (since) {
url.searchParams.set("since", since);
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
signal: abortController.signal,
});
if (!response.ok || !response.body) {
throw new Error("Unable to connect board chat stream.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!isCancelled) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const raw = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const lines = raw.split("\n");
let eventType = "message";
let data = "";
for (const line of lines) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith("data:")) {
data += line.slice(5).trim();
}
}
if (eventType === "memory" && data) {
try {
const payload = JSON.parse(data) as { memory?: BoardChatMessage };
if (payload.memory?.tags?.includes("chat")) {
setChatMessages((prev) => {
const exists = prev.some(
(item) => item.id === payload.memory?.id,
);
if (exists) return prev;
const next = [...prev, payload.memory as BoardChatMessage];
next.sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return aTime - bTime;
});
return next;
});
}
} catch {
// ignore malformed
}
}
boundary = buffer.indexOf("\n\n");
}
}
} catch {
if (!isCancelled) {
setTimeout(connect, 3000);
}
}
};
connect();
return () => {
isCancelled = true;
abortController.abort();
};
}, [boardId, getToken, isSignedIn]);
useEffect(() => {
if (!isSignedIn || !boardId) return;
let isCancelled = false;
@@ -642,6 +803,55 @@ export default function BoardDetailPage() {
}
};
const handleSendChat = async () => {
if (!isSignedIn || !boardId) return;
const trimmed = chatInput.trim();
if (!trimmed) return;
setIsChatSending(true);
setChatError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/memory`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({
content: trimmed,
tags: ["chat"],
}),
},
);
if (!response.ok) {
throw new Error("Unable to send message.");
}
const created = (await response.json()) as BoardChatMessage;
if (created.tags?.includes("chat")) {
setChatMessages((prev) => {
const exists = prev.some((item) => item.id === created.id);
if (exists) return prev;
const next = [...prev, created];
next.sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return aTime - bTime;
});
return next;
});
}
setChatInput("");
} catch (err) {
setChatError(
err instanceof Error ? err.message : "Unable to send message.",
);
} finally {
setIsChatSending(false);
}
};
const assigneeById = useMemo(() => {
const map = new Map<string, string>();
agents
@@ -652,6 +862,28 @@ export default function BoardDetailPage() {
return map;
}, [agents, boardId]);
const pendingApprovalsByTaskId = useMemo(() => {
const map = new Map<string, number>();
approvals
.filter((approval) => approval.status === "pending")
.forEach((approval) => {
const taskId = approvalTaskId(approval);
if (!taskId || typeof taskId !== "string") return;
map.set(taskId, (map.get(taskId) ?? 0) + 1);
});
return map;
}, [approvals]);
const totalApprovalsByTaskId = useMemo(() => {
const map = new Map<string, number>();
approvals.forEach((approval) => {
const taskId = approvalTaskId(approval);
if (!taskId || typeof taskId !== "string") return;
map.set(taskId, (map.get(taskId) ?? 0) + 1);
});
return map;
}, [approvals]);
const displayTasks = useMemo(
() =>
tasks.map((task) => ({
@@ -659,8 +891,10 @@ export default function BoardDetailPage() {
assignee: task.assigned_agent_id
? assigneeById.get(task.assigned_agent_id)
: undefined,
approvalsCount: totalApprovalsByTaskId.get(task.id) ?? 0,
approvalsPendingCount: pendingApprovalsByTaskId.get(task.id) ?? 0,
})),
[tasks, assigneeById],
[tasks, assigneeById, pendingApprovalsByTaskId, totalApprovalsByTaskId],
);
const boardAgents = useMemo(
@@ -712,11 +946,7 @@ export default function BoardDetailPage() {
if (!selectedTask) return [];
const taskId = selectedTask.id;
return approvals.filter((approval) => {
const payload = approval.payload ?? {};
const payloadTaskId =
(payload as Record<string, unknown>).task_id ??
(payload as Record<string, unknown>).taskId ??
(payload as Record<string, unknown>).taskID;
const payloadTaskId = approvalTaskId(approval);
return payloadTaskId === taskId;
});
}, [approvals, selectedTask]);
@@ -770,6 +1000,7 @@ export default function BoardDetailPage() {
};
const openComments = (task: Task) => {
setIsChatOpen(false);
setSelectedTask(task);
setIsDetailOpen(true);
void loadComments(task.id);
@@ -785,6 +1016,18 @@ export default function BoardDetailPage() {
setIsEditDialogOpen(false);
};
const openBoardChat = () => {
if (isDetailOpen) {
closeComments();
}
setIsChatOpen(true);
};
const closeBoardChat = () => {
setIsChatOpen(false);
setChatError(null);
};
const handlePostComment = async () => {
if (!selectedTask || !boardId || !isSignedIn) return;
const trimmed = newComment.trim();
@@ -1200,6 +1443,14 @@ export default function BoardDetailPage() {
</span>
) : null}
</Button>
<Button
variant="outline"
onClick={openBoardChat}
className="gap-2"
>
<MessageSquare className="h-4 w-4" />
Board chat
</Button>
<button
type="button"
onClick={() => router.push(`/boards/${boardId}/edit`)}
@@ -1349,6 +1600,12 @@ export default function BoardDetailPage() {
</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
{task.approvalsPendingCount ? (
<span className="inline-flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
Approval needed · {task.approvalsPendingCount}
</span>
) : null}
<span
className={cn(
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
@@ -1387,8 +1644,17 @@ export default function BoardDetailPage() {
</div>
</main>
</SignedIn>
{isDetailOpen ? (
<div className="fixed inset-0 z-40 bg-slate-900/20" onClick={closeComments} />
{isDetailOpen || isChatOpen ? (
<div
className="fixed inset-0 z-40 bg-slate-900/20"
onClick={() => {
if (isChatOpen) {
closeBoardChat();
} else {
closeComments();
}
}}
/>
) : null}
<aside
className={cn(
@@ -1622,7 +1888,10 @@ export default function BoardDetailPage() {
</aside>
<Dialog open={isApprovalsOpen} onOpenChange={setIsApprovalsOpen}>
<DialogContent aria-label="Approvals">
<DialogContent
aria-label="Approvals"
className="flex h-[85vh] max-w-3xl flex-col overflow-hidden"
>
<DialogHeader>
<DialogTitle>Approvals</DialogTitle>
<DialogDescription>
@@ -1630,18 +1899,115 @@ export default function BoardDetailPage() {
</DialogDescription>
</DialogHeader>
{boardId ? (
<BoardApprovalsPanel
boardId={boardId}
approvals={approvals}
isLoading={isApprovalsLoading}
error={approvalsError}
onDecision={handleApprovalDecision}
onRefresh={loadApprovals}
/>
<div className="flex-1 overflow-hidden">
<BoardApprovalsPanel
boardId={boardId}
approvals={approvals}
isLoading={isApprovalsLoading}
error={approvalsError}
onDecision={handleApprovalDecision}
onRefresh={loadApprovals}
scrollable
/>
</div>
) : null}
</DialogContent>
</Dialog>
<aside
className={cn(
"fixed right-0 top-0 z-50 h-full w-[560px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform",
isChatOpen ? "translate-x-0" : "translate-x-full",
)}
>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Board chat
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
Talk to the lead agent. Tag others with @name.
</p>
</div>
<button
type="button"
onClick={closeBoardChat}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
aria-label="Close board chat"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-1 flex-col overflow-hidden px-6 py-4">
<div className="flex-1 space-y-4 overflow-y-auto rounded-2xl border border-slate-200 bg-white p-4">
{chatError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{chatError}
</div>
) : null}
{chatMessages.length === 0 ? (
<p className="text-sm text-slate-500">
No messages yet. Start the conversation with your lead agent.
</p>
) : (
chatMessages.map((message) => (
<div
key={message.id}
className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-slate-900">
{message.source ?? "User"}
</p>
<span className="text-xs text-slate-400">
{formatTaskTimestamp(message.created_at)}
</span>
</div>
<div className="mt-2 text-sm text-slate-900">
<ReactMarkdown
components={{
p: ({ ...props }) => (
<p className="mb-2 last:mb-0" {...props} />
),
ul: ({ ...props }) => (
<ul className="mb-2 list-disc pl-5" {...props} />
),
ol: ({ ...props }) => (
<ol className="mb-2 list-decimal pl-5" {...props} />
),
strong: ({ ...props }) => (
<strong className="font-semibold" {...props} />
),
}}
>
{message.content}
</ReactMarkdown>
</div>
</div>
))
)}
</div>
<div className="mt-4 space-y-2">
<Textarea
value={chatInput}
onChange={(event) => setChatInput(event.target.value)}
placeholder="Message the board lead. Tag agents with @name."
className="min-h-[120px]"
/>
<div className="flex justify-end">
<Button
onClick={handleSendChat}
disabled={isChatSending || !chatInput.trim()}
>
{isChatSending ? "Sending…" : "Send"}
</Button>
</div>
</div>
</div>
</div>
</aside>
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent aria-label="Edit task">
<DialogHeader>

View File

@@ -30,6 +30,7 @@ type BoardApprovalsPanelProps = {
error?: string | null;
onRefresh?: () => void;
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
scrollable?: boolean;
};
const formatTimestamp = (value?: string | null) => {
@@ -108,12 +109,14 @@ export function BoardApprovalsPanel({
error: externalError,
onRefresh,
onDecision,
scrollable = false,
}: BoardApprovalsPanelProps) {
const { getToken, isSignedIn } = useAuth();
const [internalApprovals, setInternalApprovals] = useState<Approval[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const usingExternal = Array.isArray(externalApprovals);
const approvals = usingExternal ? externalApprovals ?? [] : internalApprovals;
const loadingState = usingExternal ? externalLoading ?? false : isLoading;
@@ -204,8 +207,20 @@ export function BoardApprovalsPanel({
return { pending, resolved };
}, [approvals]);
const toggleExpanded = useCallback((approvalId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(approvalId)) {
next.delete(approvalId);
} else {
next.add(approvalId);
}
return next;
});
}, []);
return (
<Card>
<Card className={scrollable ? "flex h-full flex-col" : undefined}>
<CardHeader className="flex flex-col gap-4 border-b border-[color:var(--border)] pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -228,7 +243,12 @@ export function BoardApprovalsPanel({
Review lead-agent decisions that require human approval.
</p>
</CardHeader>
<CardContent className="space-y-4 pt-5">
<CardContent
className={cn(
"space-y-4 pt-5",
scrollable && "flex-1 overflow-y-auto"
)}
>
{errorState ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{errorState}
@@ -246,90 +266,106 @@ export function BoardApprovalsPanel({
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
Pending
</p>
{sortedApprovals.pending.map((approval) => {
const summary = approvalSummary(approval);
return (
<div
key={approval.id}
className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-strong">
{humanizeAction(approval.action_type)}
</p>
<p className="text-xs text-muted">
Requested {formatTimestamp(approval.created_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={confidenceVariant(approval.confidence)}>
{approval.confidence}% confidence
</Badge>
<Badge variant={statusBadgeVariant(approval.status)}>
{approval.status}
</Badge>
</div>
</div>
{summary.rows.length > 0 ? (
<div className="grid gap-2 text-sm text-strong sm:grid-cols-2">
{summary.rows.map((row) => (
<div key={`${approval.id}-${row.label}`}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
{row.label}
</p>
<p className="mt-1 text-sm text-strong">
{row.value}
</p>
<div className="divide-y divide-[color:var(--border)] rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)]">
{sortedApprovals.pending.map((approval) => {
const summary = approvalSummary(approval);
const summaryLine = summary.rows
.map((row) => `${row.label}: ${row.value}`)
.join(" • ");
const detailsPayload = JSON.stringify(
{
payload: approval.payload ?? null,
rubric_scores: approval.rubric_scores ?? null,
},
null,
2
);
const isExpanded = expandedIds.has(approval.id);
return (
<div key={approval.id} className="space-y-3 px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-strong">
{humanizeAction(approval.action_type)}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
<span>
Requested {formatTimestamp(approval.created_at)}
</span>
{summaryLine ? (
<>
<span className="text-slate-300"></span>
<span className="truncate">{summaryLine}</span>
</>
) : null}
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={confidenceVariant(approval.confidence)}>
{approval.confidence}% confidence
</Badge>
<Badge variant={statusBadgeVariant(approval.status)}>
{approval.status}
</Badge>
</div>
</div>
{summary.reason ? (
<p className="text-sm text-muted">{summary.reason}</p>
) : null}
{summary.rows.length > 0 ? (
<dl className="grid gap-2 text-xs text-muted sm:grid-cols-2">
{summary.rows.map((row) => (
<div key={`${approval.id}-${row.label}`}>
<dt className="font-semibold uppercase tracking-wide text-slate-500">
{row.label}
</dt>
<dd className="mt-1 text-sm text-strong">
{row.value}
</dd>
</div>
))}
</dl>
) : null}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted">
<button
type="button"
className="font-semibold text-slate-700 hover:text-slate-900"
onClick={() => toggleExpanded(approval.id)}
>
{isExpanded ? "Hide raw" : "View raw"}
</button>
<span>JSON payload + rubric</span>
</div>
{isExpanded ? (
<pre className="max-h-40 overflow-auto rounded-xl bg-slate-950 px-3 py-3 text-[11px] text-slate-100">
{detailsPayload}
</pre>
) : null}
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
onClick={() => handleDecision(approval.id, "approved")}
disabled={updatingId === approval.id}
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDecision(approval.id, "rejected")}
disabled={updatingId === approval.id}
className={cn(
"border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
)}
>
Reject
</Button>
</div>
) : null}
{summary.reason ? (
<p className="text-sm text-muted">{summary.reason}</p>
) : null}
{approval.payload || approval.rubric_scores ? (
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
<summary className="cursor-pointer font-semibold text-strong">
Details
</summary>
{approval.payload ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
Payload: {JSON.stringify(approval.payload, null, 2)}
</pre>
) : null}
{approval.rubric_scores ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
Rubric:{" "}
{JSON.stringify(approval.rubric_scores, null, 2)}
</pre>
) : null}
</details>
) : null}
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
onClick={() => handleDecision(approval.id, "approved")}
disabled={updatingId === approval.id}
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDecision(approval.id, "rejected")}
disabled={updatingId === approval.id}
className={cn(
"border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
)}
>
Reject
</Button>
</div>
</div>
);
})}
);
})}
</div>
</div>
) : null}
{sortedApprovals.resolved.length > 0 ? (
@@ -337,69 +373,85 @@ export function BoardApprovalsPanel({
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
Resolved
</p>
{sortedApprovals.resolved.map((approval) => {
const summary = approvalSummary(approval);
return (
<div
key={approval.id}
className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-strong">
{humanizeAction(approval.action_type)}
</p>
<p className="text-xs text-muted">
Requested {formatTimestamp(approval.created_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={confidenceVariant(approval.confidence)}>
{approval.confidence}% confidence
</Badge>
<Badge variant={statusBadgeVariant(approval.status)}>
{approval.status}
</Badge>
</div>
</div>
{summary.rows.length > 0 ? (
<div className="grid gap-2 text-sm text-strong sm:grid-cols-2">
{summary.rows.map((row) => (
<div key={`${approval.id}-${row.label}`}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
{row.label}
</p>
<p className="mt-1 text-sm text-strong">
{row.value}
</p>
<div className="divide-y divide-[color:var(--border)] rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)]">
{sortedApprovals.resolved.map((approval) => {
const summary = approvalSummary(approval);
const summaryLine = summary.rows
.map((row) => `${row.label}: ${row.value}`)
.join(" • ");
const detailsPayload = JSON.stringify(
{
payload: approval.payload ?? null,
rubric_scores: approval.rubric_scores ?? null,
},
null,
2
);
const isExpanded = expandedIds.has(approval.id);
return (
<div key={approval.id} className="space-y-3 px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-strong">
{humanizeAction(approval.action_type)}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
<span>
Requested {formatTimestamp(approval.created_at)}
</span>
{summaryLine ? (
<>
<span className="text-slate-300"></span>
<span className="truncate">{summaryLine}</span>
</>
) : null}
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={confidenceVariant(approval.confidence)}>
{approval.confidence}% confidence
</Badge>
<Badge variant={statusBadgeVariant(approval.status)}>
{approval.status}
</Badge>
</div>
</div>
) : null}
{summary.reason ? (
<p className="text-sm text-muted">{summary.reason}</p>
) : null}
{approval.payload || approval.rubric_scores ? (
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
<summary className="cursor-pointer font-semibold text-strong">
Details
</summary>
{approval.payload ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
Payload: {JSON.stringify(approval.payload, null, 2)}
</pre>
) : null}
{approval.rubric_scores ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
Rubric:{" "}
{JSON.stringify(approval.rubric_scores, null, 2)}
</pre>
) : null}
</details>
) : null}
</div>
);
})}
{summary.reason ? (
<p className="text-sm text-muted">{summary.reason}</p>
) : null}
{summary.rows.length > 0 ? (
<dl className="grid gap-2 text-xs text-muted sm:grid-cols-2">
{summary.rows.map((row) => (
<div key={`${approval.id}-${row.label}`}>
<dt className="font-semibold uppercase tracking-wide text-slate-500">
{row.label}
</dt>
<dd className="mt-1 text-sm text-strong">
{row.value}
</dd>
</div>
))}
</dl>
) : null}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted">
<button
type="button"
className="font-semibold text-slate-700 hover:text-slate-900"
onClick={() => toggleExpanded(approval.id)}
>
{isExpanded ? "Hide raw" : "View raw"}
</button>
<span>JSON payload + rubric</span>
</div>
{isExpanded ? (
<pre className="max-h-40 overflow-auto rounded-xl bg-slate-950 px-3 py-3 text-[11px] text-slate-100">
{detailsPayload}
</pre>
) : null}
</div>
);
})}
</div>
</div>
) : null}
</div>

View File

@@ -7,6 +7,8 @@ interface TaskCardProps {
priority?: string;
assignee?: string;
due?: string;
approvalsCount?: number;
approvalsPendingCount?: number;
onClick?: () => void;
draggable?: boolean;
isDragging?: boolean;
@@ -19,12 +21,15 @@ export function TaskCard({
priority,
assignee,
due,
approvalsCount = 0,
approvalsPendingCount = 0,
onClick,
draggable = false,
isDragging = false,
onDragStart,
onDragEnd,
}: TaskCardProps) {
const hasPendingApproval = approvalsPendingCount > 0;
const priorityBadge = (value?: string) => {
if (!value) return null;
const normalized = value.toLowerCase();
@@ -45,8 +50,9 @@ export function TaskCard({
return (
<div
className={cn(
"group cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md",
"group relative cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md",
isDragging && "opacity-60 shadow-none",
hasPendingApproval && "border-amber-200 bg-amber-50/40",
)}
draggable={draggable}
onDragStart={onDragStart}
@@ -61,18 +67,29 @@ export function TaskCard({
}
}}
>
{hasPendingApproval ? (
<span className="absolute left-0 top-0 h-full w-1 rounded-l-lg bg-amber-400" />
) : null}
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-900">{title}</p>
{hasPendingApproval ? (
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
Approval needed · {approvalsPendingCount}
</div>
) : null}
</div>
<div className="flex flex-col items-end gap-2">
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
priorityBadge(priority) ?? "bg-slate-100 text-slate-600",
)}
>
{priorityLabel}
</span>
</div>
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
priorityBadge(priority) ?? "bg-slate-100 text-slate-600",
)}
>
{priorityLabel}
</span>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-2">

View File

@@ -14,6 +14,8 @@ type Task = {
due_at?: string | null;
assigned_agent_id?: string | null;
assignee?: string;
approvalsCount?: number;
approvalsPendingCount?: number;
};
type TaskBoardProps = {
@@ -173,17 +175,19 @@ export function TaskBoard({
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
<div className="space-y-3">
{columnTasks.map((task) => (
<TaskCard
key={task.id}
title={task.title}
priority={task.priority}
assignee={task.assignee}
due={formatDueDate(task.due_at)}
onClick={() => onTaskSelect?.(task)}
draggable
isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)}
onDragEnd={handleDragEnd}
<TaskCard
key={task.id}
title={task.title}
priority={task.priority}
assignee={task.assignee}
due={formatDueDate(task.due_at)}
approvalsCount={task.approvalsCount}
approvalsPendingCount={task.approvalsPendingCount}
onClick={() => onTaskSelect?.(task)}
draggable
isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)}
onDragEnd={handleDragEnd}
/>
))}
</div>