feat(ui): improve live feed cards
This commit is contained in:
@@ -4,7 +4,14 @@ import { memo, useCallback, useEffect, useMemo, useRef, 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 { Activity, MessageSquare, Pencil, Settings, X } from "lucide-react";
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowUpRight,
|
||||||
|
MessageSquare,
|
||||||
|
Pencil,
|
||||||
|
Settings,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import ReactMarkdown, { type Components } from "react-markdown";
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||||||
import remarkBreaks from "remark-breaks";
|
import remarkBreaks from "remark-breaks";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -140,10 +147,7 @@ const SSE_RECONNECT_BACKOFF = {
|
|||||||
const MARKDOWN_TABLE_COMPONENTS: Components = {
|
const MARKDOWN_TABLE_COMPONENTS: Components = {
|
||||||
table: ({ node: _node, className, ...props }) => (
|
table: ({ node: _node, className, ...props }) => (
|
||||||
<div className="my-3 overflow-x-auto">
|
<div className="my-3 overflow-x-auto">
|
||||||
<table
|
<table className={cn("w-full border-collapse", className)} {...props} />
|
||||||
className={cn("w-full border-collapse", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
thead: ({ node: _node, className, ...props }) => (
|
thead: ({ node: _node, className, ...props }) => (
|
||||||
@@ -240,7 +244,9 @@ const Markdown = memo(function Markdown({
|
|||||||
? MARKDOWN_REMARK_PLUGINS_WITH_BREAKS
|
? MARKDOWN_REMARK_PLUGINS_WITH_BREAKS
|
||||||
: MARKDOWN_REMARK_PLUGINS_BASIC;
|
: MARKDOWN_REMARK_PLUGINS_BASIC;
|
||||||
const components =
|
const components =
|
||||||
variant === "description" ? MARKDOWN_COMPONENTS_DESCRIPTION : MARKDOWN_COMPONENTS_BASIC;
|
variant === "description"
|
||||||
|
? MARKDOWN_COMPONENTS_DESCRIPTION
|
||||||
|
: MARKDOWN_COMPONENTS_BASIC;
|
||||||
return (
|
return (
|
||||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
||||||
{trimmed}
|
{trimmed}
|
||||||
@@ -296,7 +302,9 @@ const ChatMessageCard = memo(function ChatMessageCard({
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4">
|
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="text-sm font-semibold text-slate-900">{message.source ?? "User"}</p>
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
{message.source ?? "User"}
|
||||||
|
</p>
|
||||||
<span className="text-xs text-slate-400">
|
<span className="text-xs text-slate-400">
|
||||||
{formatShortTimestamp(message.created_at)}
|
{formatShortTimestamp(message.created_at)}
|
||||||
</span>
|
</span>
|
||||||
@@ -313,56 +321,80 @@ ChatMessageCard.displayName = "ChatMessageCard";
|
|||||||
const LiveFeedCard = memo(function LiveFeedCard({
|
const LiveFeedCard = memo(function LiveFeedCard({
|
||||||
comment,
|
comment,
|
||||||
taskTitle,
|
taskTitle,
|
||||||
authorLabel,
|
authorName,
|
||||||
|
authorRole,
|
||||||
|
authorAvatar,
|
||||||
onViewTask,
|
onViewTask,
|
||||||
}: {
|
}: {
|
||||||
comment: TaskComment;
|
comment: TaskComment;
|
||||||
taskTitle: string;
|
taskTitle: string;
|
||||||
authorLabel: string;
|
authorName: string;
|
||||||
|
authorRole?: string | null;
|
||||||
|
authorAvatar: string;
|
||||||
onViewTask?: () => void;
|
onViewTask?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const message = (comment.message ?? "").trim();
|
const message = (comment.message ?? "").trim();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
<div className="rounded-xl border border-slate-200 bg-white p-3 transition hover:border-slate-300">
|
||||||
<div className="flex items-start justify-between gap-3 text-xs text-slate-500">
|
<div className="flex items-start gap-3">
|
||||||
<div className="min-w-0">
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
|
||||||
<button
|
{authorAvatar}
|
||||||
type="button"
|
|
||||||
onClick={onViewTask}
|
|
||||||
disabled={!onViewTask}
|
|
||||||
className={cn(
|
|
||||||
"block truncate text-left text-xs font-semibold text-slate-700",
|
|
||||||
onViewTask
|
|
||||||
? "cursor-pointer transition hover:text-slate-900 hover:underline"
|
|
||||||
: "cursor-default",
|
|
||||||
)}
|
|
||||||
title={onViewTask ? "View task" : undefined}
|
|
||||||
>
|
|
||||||
{taskTitle}
|
|
||||||
</button>
|
|
||||||
<p className="mt-1 text-[11px] text-slate-400">{authorLabel}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1 text-right">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-[11px] text-slate-400">
|
<div className="flex items-start justify-between gap-2">
|
||||||
{formatShortTimestamp(comment.created_at)}
|
|
||||||
</span>
|
|
||||||
{onViewTask ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onViewTask}
|
onClick={onViewTask}
|
||||||
className="text-[11px] font-semibold text-slate-600 transition hover:text-slate-900"
|
disabled={!onViewTask}
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm font-semibold leading-snug text-slate-900",
|
||||||
|
onViewTask
|
||||||
|
? "cursor-pointer transition hover:text-slate-950 hover:underline"
|
||||||
|
: "cursor-default",
|
||||||
|
)}
|
||||||
|
title={taskTitle}
|
||||||
|
style={{
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
View task
|
{taskTitle}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
{onViewTask ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onViewTask}
|
||||||
|
className="inline-flex flex-shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:bg-slate-50 hover:text-slate-900"
|
||||||
|
aria-label="View task"
|
||||||
|
>
|
||||||
|
View task
|
||||||
|
<ArrowUpRight className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
|
||||||
|
<span className="font-medium text-slate-700">{authorName}</span>
|
||||||
|
{authorRole ? (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-300">·</span>
|
||||||
|
<span className="text-slate-500">{authorRole}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<span className="text-slate-300">·</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{formatShortTimestamp(comment.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{message ? (
|
{message ? (
|
||||||
<div className="mt-2 select-text cursor-text text-xs leading-relaxed text-slate-900 break-words">
|
<div className="mt-3 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
|
||||||
<Markdown content={message} variant="basic" />
|
<Markdown content={message} variant="basic" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-xs text-slate-500">—</p>
|
<p className="mt-3 text-sm text-slate-500">—</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -519,9 +551,8 @@ export default function BoardDetailPage() {
|
|||||||
setApprovalsError(null);
|
setApprovalsError(null);
|
||||||
setChatError(null);
|
setChatError(null);
|
||||||
try {
|
try {
|
||||||
const snapshotResult = await getBoardSnapshotApiV1BoardsBoardIdSnapshotGet(
|
const snapshotResult =
|
||||||
boardId,
|
await getBoardSnapshotApiV1BoardsBoardIdSnapshotGet(boardId);
|
||||||
);
|
|
||||||
if (snapshotResult.status !== 200) {
|
if (snapshotResult.status !== 200) {
|
||||||
throw new Error("Unable to load board snapshot.");
|
throw new Error("Unable to load board snapshot.");
|
||||||
}
|
}
|
||||||
@@ -532,7 +563,8 @@ export default function BoardDetailPage() {
|
|||||||
setApprovals((snapshot.approvals ?? []).map(normalizeApproval));
|
setApprovals((snapshot.approvals ?? []).map(normalizeApproval));
|
||||||
setChatMessages(snapshot.chat_messages ?? []);
|
setChatMessages(snapshot.chat_messages ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Something went wrong.";
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Something went wrong.";
|
||||||
setError(message);
|
setError(message);
|
||||||
setApprovalsError(message);
|
setApprovalsError(message);
|
||||||
setChatError(message);
|
setChatError(message);
|
||||||
@@ -641,21 +673,23 @@ export default function BoardDetailPage() {
|
|||||||
}
|
}
|
||||||
if (eventType === "memory" && data) {
|
if (eventType === "memory" && data) {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(data) as { memory?: BoardChatMessage };
|
const payload = JSON.parse(data) as {
|
||||||
|
memory?: BoardChatMessage;
|
||||||
|
};
|
||||||
if (payload.memory?.tags?.includes("chat")) {
|
if (payload.memory?.tags?.includes("chat")) {
|
||||||
setChatMessages((prev) => {
|
setChatMessages((prev) => {
|
||||||
const exists = prev.some(
|
const exists = prev.some(
|
||||||
(item) => item.id === payload.memory?.id,
|
(item) => item.id === payload.memory?.id,
|
||||||
);
|
);
|
||||||
if (exists) return prev;
|
if (exists) return prev;
|
||||||
const next = [...prev, payload.memory as BoardChatMessage];
|
const next = [...prev, payload.memory as BoardChatMessage];
|
||||||
next.sort((a, b) => {
|
next.sort((a, b) => {
|
||||||
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
||||||
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
||||||
return aTime - bTime;
|
return aTime - bTime;
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed
|
// ignore malformed
|
||||||
@@ -900,41 +934,53 @@ export default function BoardDetailPage() {
|
|||||||
task?: TaskRead;
|
task?: TaskRead;
|
||||||
comment?: TaskCommentRead;
|
comment?: TaskCommentRead;
|
||||||
};
|
};
|
||||||
if (payload.comment?.task_id && payload.type === "task.comment") {
|
if (
|
||||||
|
payload.comment?.task_id &&
|
||||||
|
payload.type === "task.comment"
|
||||||
|
) {
|
||||||
pushLiveFeed(payload.comment);
|
pushLiveFeed(payload.comment);
|
||||||
setComments((prev) => {
|
setComments((prev) => {
|
||||||
if (selectedTaskIdRef.current !== payload.comment?.task_id) {
|
if (
|
||||||
|
selectedTaskIdRef.current !== payload.comment?.task_id
|
||||||
|
) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const exists = prev.some((item) => item.id === payload.comment?.id);
|
const exists = prev.some(
|
||||||
|
(item) => item.id === payload.comment?.id,
|
||||||
|
);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const createdMs = apiDatetimeToMs(payload.comment?.created_at);
|
const createdMs = apiDatetimeToMs(
|
||||||
if (prev.length === 0 || createdMs === null) {
|
payload.comment?.created_at,
|
||||||
return [...prev, payload.comment as TaskComment];
|
);
|
||||||
}
|
if (prev.length === 0 || createdMs === null) {
|
||||||
const last = prev[prev.length - 1];
|
return [...prev, payload.comment as TaskComment];
|
||||||
const lastMs = apiDatetimeToMs(last?.created_at);
|
}
|
||||||
if (lastMs !== null && createdMs >= lastMs) {
|
const last = prev[prev.length - 1];
|
||||||
return [...prev, payload.comment as TaskComment];
|
const lastMs = apiDatetimeToMs(last?.created_at);
|
||||||
}
|
if (lastMs !== null && createdMs >= lastMs) {
|
||||||
const next = [...prev, payload.comment as TaskComment];
|
return [...prev, payload.comment as TaskComment];
|
||||||
next.sort((a, b) => {
|
}
|
||||||
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
const next = [...prev, payload.comment as TaskComment];
|
||||||
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
next.sort((a, b) => {
|
||||||
return aTime - bTime;
|
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
||||||
});
|
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
||||||
return next;
|
return aTime - bTime;
|
||||||
});
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} else if (payload.task) {
|
} else if (payload.task) {
|
||||||
setTasks((prev) => {
|
setTasks((prev) => {
|
||||||
const index = prev.findIndex((item) => item.id === payload.task?.id);
|
const index = prev.findIndex(
|
||||||
|
(item) => item.id === payload.task?.id,
|
||||||
|
);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
const assignee = payload.task?.assigned_agent_id
|
const assignee = payload.task?.assigned_agent_id
|
||||||
? agentsRef.current.find(
|
? (agentsRef.current.find(
|
||||||
(agent) => agent.id === payload.task?.assigned_agent_id,
|
(agent) =>
|
||||||
)?.name ?? null
|
agent.id === payload.task?.assigned_agent_id,
|
||||||
|
)?.name ?? null)
|
||||||
: null;
|
: null;
|
||||||
const created = normalizeTask({
|
const created = normalizeTask({
|
||||||
...payload.task,
|
...payload.task,
|
||||||
@@ -947,9 +993,10 @@ export default function BoardDetailPage() {
|
|||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
const existing = next[index];
|
const existing = next[index];
|
||||||
const assignee = payload.task?.assigned_agent_id
|
const assignee = payload.task?.assigned_agent_id
|
||||||
? agentsRef.current.find(
|
? (agentsRef.current.find(
|
||||||
(agent) => agent.id === payload.task?.assigned_agent_id,
|
(agent) =>
|
||||||
)?.name ?? null
|
agent.id === payload.task?.assigned_agent_id,
|
||||||
|
)?.name ?? null)
|
||||||
: null;
|
: null;
|
||||||
const updated = normalizeTask({
|
const updated = normalizeTask({
|
||||||
...existing,
|
...existing,
|
||||||
@@ -1054,7 +1101,9 @@ export default function BoardDetailPage() {
|
|||||||
if (payload.agent) {
|
if (payload.agent) {
|
||||||
const normalized = normalizeAgent(payload.agent);
|
const normalized = normalizeAgent(payload.agent);
|
||||||
setAgents((prev) => {
|
setAgents((prev) => {
|
||||||
const index = prev.findIndex((item) => item.id === normalized.id);
|
const index = prev.findIndex(
|
||||||
|
(item) => item.id === normalized.id,
|
||||||
|
);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
return [normalized, ...prev];
|
return [normalized, ...prev];
|
||||||
}
|
}
|
||||||
@@ -1127,7 +1176,7 @@ export default function BoardDetailPage() {
|
|||||||
const created = normalizeTask({
|
const created = normalizeTask({
|
||||||
...result.data,
|
...result.data,
|
||||||
assignee: result.data.assigned_agent_id
|
assignee: result.data.assigned_agent_id
|
||||||
? assigneeById.get(result.data.assigned_agent_id) ?? null
|
? (assigneeById.get(result.data.assigned_agent_id) ?? null)
|
||||||
: null,
|
: null,
|
||||||
approvals_count: 0,
|
approvals_count: 0,
|
||||||
approvals_pending_count: 0,
|
approvals_pending_count: 0,
|
||||||
@@ -1136,50 +1185,58 @@ export default function BoardDetailPage() {
|
|||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCreateError(err instanceof Error ? err.message : "Something went wrong.");
|
setCreateError(
|
||||||
|
err instanceof Error ? err.message : "Something went wrong.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendChat = useCallback(async (content: string): Promise<boolean> => {
|
const handleSendChat = useCallback(
|
||||||
if (!isSignedIn || !boardId) return false;
|
async (content: string): Promise<boolean> => {
|
||||||
const trimmed = content.trim();
|
if (!isSignedIn || !boardId) return false;
|
||||||
if (!trimmed) return false;
|
const trimmed = content.trim();
|
||||||
setIsChatSending(true);
|
if (!trimmed) return false;
|
||||||
setChatError(null);
|
setIsChatSending(true);
|
||||||
try {
|
setChatError(null);
|
||||||
const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost(boardId, {
|
try {
|
||||||
content: trimmed,
|
const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost(
|
||||||
tags: ["chat"],
|
boardId,
|
||||||
});
|
{
|
||||||
if (result.status !== 200) {
|
content: trimmed,
|
||||||
throw new Error("Unable to send message.");
|
tags: ["chat"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
throw new Error("Unable to send message.");
|
||||||
|
}
|
||||||
|
const created = result.data;
|
||||||
|
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 = apiDatetimeToMs(a.created_at) ?? 0;
|
||||||
|
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
||||||
|
return aTime - bTime;
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setChatError(
|
||||||
|
err instanceof Error ? err.message : "Unable to send message.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsChatSending(false);
|
||||||
}
|
}
|
||||||
const created = result.data;
|
},
|
||||||
if (created.tags?.includes("chat")) {
|
[boardId, isSignedIn],
|
||||||
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 = apiDatetimeToMs(a.created_at) ?? 0;
|
|
||||||
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
|
||||||
return aTime - bTime;
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
setChatError(
|
|
||||||
err instanceof Error ? err.message : "Unable to send message.",
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsChatSending(false);
|
|
||||||
}
|
|
||||||
}, [boardId, isSignedIn]);
|
|
||||||
|
|
||||||
const assigneeById = useMemo(() => {
|
const assigneeById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -1307,41 +1364,49 @@ export default function BoardDetailPage() {
|
|||||||
});
|
});
|
||||||
}, [agents, workingAgentIds]);
|
}, [agents, workingAgentIds]);
|
||||||
|
|
||||||
const loadComments = useCallback(async (taskId: string) => {
|
const loadComments = useCallback(
|
||||||
if (!isSignedIn || !boardId) return;
|
async (taskId: string) => {
|
||||||
setIsCommentsLoading(true);
|
if (!isSignedIn || !boardId) return;
|
||||||
setCommentsError(null);
|
setIsCommentsLoading(true);
|
||||||
try {
|
setCommentsError(null);
|
||||||
const result =
|
try {
|
||||||
await listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGet(
|
const result =
|
||||||
boardId,
|
await listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGet(
|
||||||
taskId,
|
boardId,
|
||||||
|
taskId,
|
||||||
|
);
|
||||||
|
if (result.status !== 200) throw new Error("Unable to load comments.");
|
||||||
|
const items = [...(result.data.items ?? [])];
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
||||||
|
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
||||||
|
return aTime - bTime;
|
||||||
|
});
|
||||||
|
setComments(items);
|
||||||
|
} catch (err) {
|
||||||
|
setCommentsError(
|
||||||
|
err instanceof Error ? err.message : "Something went wrong.",
|
||||||
);
|
);
|
||||||
if (result.status !== 200) throw new Error("Unable to load comments.");
|
} finally {
|
||||||
const items = [...(result.data.items ?? [])];
|
setIsCommentsLoading(false);
|
||||||
items.sort((a, b) => {
|
}
|
||||||
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
},
|
||||||
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
[boardId, isSignedIn],
|
||||||
return aTime - bTime;
|
);
|
||||||
});
|
|
||||||
setComments(items);
|
|
||||||
} catch (err) {
|
|
||||||
setCommentsError(err instanceof Error ? err.message : "Something went wrong.");
|
|
||||||
} finally {
|
|
||||||
setIsCommentsLoading(false);
|
|
||||||
}
|
|
||||||
}, [boardId, isSignedIn]);
|
|
||||||
|
|
||||||
const openComments = useCallback((task: { id: string }) => {
|
const openComments = useCallback(
|
||||||
setIsChatOpen(false);
|
(task: { id: string }) => {
|
||||||
setIsLiveFeedOpen(false);
|
setIsChatOpen(false);
|
||||||
const fullTask = tasksRef.current.find((item) => item.id === task.id);
|
setIsLiveFeedOpen(false);
|
||||||
if (!fullTask) return;
|
const fullTask = tasksRef.current.find((item) => item.id === task.id);
|
||||||
selectedTaskIdRef.current = fullTask.id;
|
if (!fullTask) return;
|
||||||
setSelectedTask(fullTask);
|
selectedTaskIdRef.current = fullTask.id;
|
||||||
setIsDetailOpen(true);
|
setSelectedTask(fullTask);
|
||||||
void loadComments(task.id);
|
setIsDetailOpen(true);
|
||||||
}, [loadComments]);
|
void loadComments(task.id);
|
||||||
|
},
|
||||||
|
[loadComments],
|
||||||
|
);
|
||||||
|
|
||||||
const closeComments = () => {
|
const closeComments = () => {
|
||||||
setIsDetailOpen(false);
|
setIsDetailOpen(false);
|
||||||
@@ -1470,20 +1535,24 @@ export default function BoardDetailPage() {
|
|||||||
...previous,
|
...previous,
|
||||||
...result.data,
|
...result.data,
|
||||||
assignee: result.data.assigned_agent_id
|
assignee: result.data.assigned_agent_id
|
||||||
? assigneeById.get(result.data.assigned_agent_id) ?? null
|
? (assigneeById.get(result.data.assigned_agent_id) ?? null)
|
||||||
: null,
|
: null,
|
||||||
approvals_count: previous.approvals_count,
|
approvals_count: previous.approvals_count,
|
||||||
approvals_pending_count: previous.approvals_pending_count,
|
approvals_pending_count: previous.approvals_pending_count,
|
||||||
} as TaskCardRead);
|
} as TaskCardRead);
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
prev.map((task) => (task.id === updated.id ? { ...task, ...updated } : task)),
|
prev.map((task) =>
|
||||||
|
task.id === updated.id ? { ...task, ...updated } : task,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setSelectedTask(updated);
|
setSelectedTask(updated);
|
||||||
if (closeOnSuccess) {
|
if (closeOnSuccess) {
|
||||||
setIsEditDialogOpen(false);
|
setIsEditDialogOpen(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSaveTaskError(err instanceof Error ? err.message : "Something went wrong.");
|
setSaveTaskError(
|
||||||
|
err instanceof Error ? err.message : "Something went wrong.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingTask(false);
|
setIsSavingTask(false);
|
||||||
}
|
}
|
||||||
@@ -1522,69 +1591,76 @@ export default function BoardDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTaskMove = useCallback(async (taskId: string, status: TaskStatus) => {
|
const handleTaskMove = useCallback(
|
||||||
if (!isSignedIn || !boardId) return;
|
async (taskId: string, status: TaskStatus) => {
|
||||||
const currentTask = tasksRef.current.find((task) => task.id === taskId);
|
if (!isSignedIn || !boardId) return;
|
||||||
if (!currentTask || currentTask.status === status) return;
|
const currentTask = tasksRef.current.find((task) => task.id === taskId);
|
||||||
if (currentTask.is_blocked && status !== "inbox") {
|
if (!currentTask || currentTask.status === status) return;
|
||||||
setError("Task is blocked by incomplete dependencies.");
|
if (currentTask.is_blocked && status !== "inbox") {
|
||||||
return;
|
setError("Task is blocked by incomplete dependencies.");
|
||||||
}
|
return;
|
||||||
const previousTasks = tasksRef.current;
|
|
||||||
setTasks((prev) =>
|
|
||||||
prev.map((task) =>
|
|
||||||
task.id === taskId
|
|
||||||
? {
|
|
||||||
...task,
|
|
||||||
status,
|
|
||||||
assigned_agent_id:
|
|
||||||
status === "inbox" ? null : task.assigned_agent_id,
|
|
||||||
assignee: status === "inbox" ? null : task.assignee,
|
|
||||||
}
|
|
||||||
: task,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
|
|
||||||
boardId,
|
|
||||||
taskId,
|
|
||||||
{ status },
|
|
||||||
);
|
|
||||||
if (result.status === 409) {
|
|
||||||
const blockedIds = result.data.detail.blocked_by_task_ids ?? [];
|
|
||||||
const blockedTitles = blockedIds
|
|
||||||
.map((id) => taskTitleById.get(id) ?? id)
|
|
||||||
.join(", ");
|
|
||||||
throw new Error(
|
|
||||||
blockedTitles
|
|
||||||
? `${result.data.detail.message} Blocked by: ${blockedTitles}`
|
|
||||||
: result.data.detail.message,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (result.status === 422) {
|
const previousTasks = tasksRef.current;
|
||||||
throw new Error(
|
|
||||||
result.data.detail?.[0]?.msg ?? "Validation error while moving task.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const assignee = result.data.assigned_agent_id
|
|
||||||
? agentsRef.current.find((agent) => agent.id === result.data.assigned_agent_id)
|
|
||||||
?.name ?? null
|
|
||||||
: null;
|
|
||||||
const updated = normalizeTask({
|
|
||||||
...currentTask,
|
|
||||||
...result.data,
|
|
||||||
assignee,
|
|
||||||
approvals_count: currentTask.approvals_count,
|
|
||||||
approvals_pending_count: currentTask.approvals_pending_count,
|
|
||||||
} as TaskCardRead);
|
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
prev.map((task) => (task.id === updated.id ? { ...task, ...updated } : task)),
|
prev.map((task) =>
|
||||||
|
task.id === taskId
|
||||||
|
? {
|
||||||
|
...task,
|
||||||
|
status,
|
||||||
|
assigned_agent_id:
|
||||||
|
status === "inbox" ? null : task.assigned_agent_id,
|
||||||
|
assignee: status === "inbox" ? null : task.assignee,
|
||||||
|
}
|
||||||
|
: task,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
try {
|
||||||
setTasks(previousTasks);
|
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
|
||||||
setError(err instanceof Error ? err.message : "Unable to move task.");
|
boardId,
|
||||||
}
|
taskId,
|
||||||
}, [boardId, isSignedIn, taskTitleById]);
|
{ status },
|
||||||
|
);
|
||||||
|
if (result.status === 409) {
|
||||||
|
const blockedIds = result.data.detail.blocked_by_task_ids ?? [];
|
||||||
|
const blockedTitles = blockedIds
|
||||||
|
.map((id) => taskTitleById.get(id) ?? id)
|
||||||
|
.join(", ");
|
||||||
|
throw new Error(
|
||||||
|
blockedTitles
|
||||||
|
? `${result.data.detail.message} Blocked by: ${blockedTitles}`
|
||||||
|
: result.data.detail.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.status === 422) {
|
||||||
|
throw new Error(
|
||||||
|
result.data.detail?.[0]?.msg ??
|
||||||
|
"Validation error while moving task.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const assignee = result.data.assigned_agent_id
|
||||||
|
? (agentsRef.current.find(
|
||||||
|
(agent) => agent.id === result.data.assigned_agent_id,
|
||||||
|
)?.name ?? null)
|
||||||
|
: null;
|
||||||
|
const updated = normalizeTask({
|
||||||
|
...currentTask,
|
||||||
|
...result.data,
|
||||||
|
assignee,
|
||||||
|
approvals_count: currentTask.approvals_count,
|
||||||
|
approvals_pending_count: currentTask.approvals_pending_count,
|
||||||
|
} as TaskCardRead);
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((task) =>
|
||||||
|
task.id === updated.id ? { ...task, ...updated } : task,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setTasks(previousTasks);
|
||||||
|
setError(err instanceof Error ? err.message : "Unable to move task.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[boardId, isSignedIn, taskTitleById],
|
||||||
|
);
|
||||||
|
|
||||||
const agentInitials = (agent: Agent) =>
|
const agentInitials = (agent: Agent) =>
|
||||||
agent.name
|
agent.name
|
||||||
@@ -1608,7 +1684,8 @@ export default function BoardDetailPage() {
|
|||||||
if (agent.is_board_lead) return "⚙️";
|
if (agent.is_board_lead) return "⚙️";
|
||||||
let emojiValue: string | null = null;
|
let emojiValue: string | null = null;
|
||||||
if (agent.identity_profile && typeof agent.identity_profile === "object") {
|
if (agent.identity_profile && typeof agent.identity_profile === "object") {
|
||||||
const rawEmoji = (agent.identity_profile as Record<string, unknown>).emoji;
|
const rawEmoji = (agent.identity_profile as Record<string, unknown>)
|
||||||
|
.emoji;
|
||||||
emojiValue = typeof rawEmoji === "string" ? rawEmoji : null;
|
emojiValue = typeof rawEmoji === "string" ? rawEmoji : null;
|
||||||
}
|
}
|
||||||
const emoji = resolveEmoji(emojiValue);
|
const emoji = resolveEmoji(emojiValue);
|
||||||
@@ -1683,16 +1760,11 @@ export default function BoardDetailPage() {
|
|||||||
value
|
value
|
||||||
.split(".")
|
.split(".")
|
||||||
.map((part) =>
|
.map((part) =>
|
||||||
part
|
part.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()),
|
||||||
.replace(/_/g, " ")
|
|
||||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
|
||||||
)
|
)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|
||||||
const approvalPayloadValue = (
|
const approvalPayloadValue = (payload: Approval["payload"], key: string) => {
|
||||||
payload: Approval["payload"],
|
|
||||||
key: string,
|
|
||||||
) => {
|
|
||||||
if (!payload || typeof payload !== "object") return null;
|
if (!payload || typeof payload !== "object") return null;
|
||||||
const value = (payload as Record<string, unknown>)[key];
|
const value = (payload as Record<string, unknown>)[key];
|
||||||
if (typeof value === "string" || typeof value === "number") {
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
@@ -2001,7 +2073,8 @@ export default function BoardDetailPage() {
|
|||||||
{task.approvals_pending_count ? (
|
{task.approvals_pending_count ? (
|
||||||
<span className="inline-flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
|
<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" />
|
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||||
Approval needed · {task.approvals_pending_count}
|
Approval needed ·{" "}
|
||||||
|
{task.approvals_pending_count}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
<span
|
||||||
@@ -2056,22 +2129,22 @@ export default function BoardDetailPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed right-0 top-0 z-50 h-full w-[max(760px,45vw)] max-w-[99vw] transform bg-white shadow-2xl transition-transform",
|
"fixed right-0 top-0 z-50 h-full w-[max(760px,45vw)] max-w-[99vw] transform bg-white shadow-2xl transition-transform",
|
||||||
isDetailOpen ? "transform-none" : "translate-x-full",
|
isDetailOpen ? "transform-none" : "translate-x-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Task detail
|
Task detail
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||||
{selectedTask?.title ?? "Task"}
|
{selectedTask?.title ?? "Task"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2089,7 +2162,7 @@ export default function BoardDetailPage() {
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
|
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
@@ -2097,10 +2170,15 @@ export default function BoardDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
{selectedTask?.description ? (
|
{selectedTask?.description ? (
|
||||||
<div className="prose prose-sm max-w-none text-slate-700">
|
<div className="prose prose-sm max-w-none text-slate-700">
|
||||||
<Markdown content={selectedTask.description} variant="description" />
|
<Markdown
|
||||||
|
content={selectedTask.description}
|
||||||
|
variant="description"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-slate-500">No description provided.</p>
|
<p className="text-sm text-slate-500">
|
||||||
|
No description provided.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -2204,7 +2282,8 @@ export default function BoardDetailPage() {
|
|||||||
{humanizeApprovalAction(approval.action_type)}
|
{humanizeApprovalAction(approval.action_type)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
Requested {formatApprovalTimestamp(approval.created_at)}
|
Requested{" "}
|
||||||
|
{formatApprovalTimestamp(approval.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold text-slate-700">
|
<span className="text-xs font-semibold text-slate-700">
|
||||||
@@ -2299,7 +2378,7 @@ export default function BoardDetailPage() {
|
|||||||
comment={comment}
|
comment={comment}
|
||||||
authorLabel={
|
authorLabel={
|
||||||
comment.agent_id
|
comment.agent_id
|
||||||
? assigneeById.get(comment.agent_id) ?? "Agent"
|
? (assigneeById.get(comment.agent_id) ?? "Agent")
|
||||||
: "Admin"
|
: "Admin"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -2311,12 +2390,12 @@ export default function BoardDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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",
|
"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 ? "transform-none" : "translate-x-full",
|
isChatOpen ? "transform-none" : "translate-x-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -2336,10 +2415,10 @@ export default function BoardDetailPage() {
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col overflow-hidden px-6 py-4">
|
<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">
|
<div className="flex-1 space-y-4 overflow-y-auto rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
{chatError ? (
|
{chatError ? (
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
{chatError}
|
{chatError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2351,20 +2430,23 @@ export default function BoardDetailPage() {
|
|||||||
chatMessages.map((message) => (
|
chatMessages.map((message) => (
|
||||||
<ChatMessageCard key={message.id} message={message} />
|
<ChatMessageCard key={message.id} message={message} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<div ref={chatEndRef} />
|
<div ref={chatEndRef} />
|
||||||
</div>
|
</div>
|
||||||
<BoardChatComposer isSending={isChatSending} onSend={handleSendChat} />
|
<BoardChatComposer
|
||||||
</div>
|
isSending={isChatSending}
|
||||||
</div>
|
onSend={handleSendChat}
|
||||||
</aside>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed right-0 top-0 z-50 h-full w-[520px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform",
|
"fixed right-0 top-0 z-50 h-full w-[520px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform",
|
||||||
isLiveFeedOpen ? "transform-none" : "translate-x-full",
|
isLiveFeedOpen ? "transform-none" : "translate-x-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -2393,18 +2475,27 @@ export default function BoardDetailPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{orderedLiveFeed.map((comment) => {
|
{orderedLiveFeed.map((comment) => {
|
||||||
const taskId = comment.task_id;
|
const taskId = comment.task_id;
|
||||||
|
const authorAgent = comment.agent_id
|
||||||
|
? (agents.find((agent) => agent.id === comment.agent_id) ??
|
||||||
|
null)
|
||||||
|
: null;
|
||||||
|
const authorName = authorAgent ? authorAgent.name : "Admin";
|
||||||
|
const authorRole = authorAgent
|
||||||
|
? agentRoleLabel(authorAgent)
|
||||||
|
: null;
|
||||||
|
const authorAvatar = authorAgent
|
||||||
|
? agentAvatarLabel(authorAgent)
|
||||||
|
: "A";
|
||||||
return (
|
return (
|
||||||
<LiveFeedCard
|
<LiveFeedCard
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
comment={comment}
|
comment={comment}
|
||||||
taskTitle={
|
taskTitle={
|
||||||
taskId ? taskTitleById.get(taskId) ?? "Task" : "Task"
|
taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task"
|
||||||
}
|
|
||||||
authorLabel={
|
|
||||||
comment.agent_id
|
|
||||||
? assigneeById.get(comment.agent_id) ?? "Agent"
|
|
||||||
: "Admin"
|
|
||||||
}
|
}
|
||||||
|
authorName={authorName}
|
||||||
|
authorRole={authorRole}
|
||||||
|
authorAvatar={authorAvatar}
|
||||||
onViewTask={
|
onViewTask={
|
||||||
taskId ? () => openComments({ id: taskId }) : undefined
|
taskId ? () => openComments({ id: taskId }) : undefined
|
||||||
}
|
}
|
||||||
@@ -2713,16 +2804,10 @@ export default function BoardDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsDialogOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleCreateTask} disabled={isCreating}>
|
||||||
onClick={handleCreateTask}
|
|
||||||
disabled={isCreating}
|
|
||||||
>
|
|
||||||
{isCreating ? "Creating…" : "Create task"}
|
{isCreating ? "Creating…" : "Create task"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
Reference in New Issue
Block a user