Pipeline/frontend/src/app/boards/[boardId]/edit/page.tsx

1307 lines
47 KiB
TypeScript

"use client";
export const dynamic = "force-dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { X } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import {
type getBoardApiV1BoardsBoardIdGetResponse,
useGetBoardApiV1BoardsBoardIdGet,
useUpdateBoardApiV1BoardsBoardIdPatch,
} from "@/api/generated/boards/boards";
import {
type listAgentsApiV1AgentsGetResponse,
useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents";
import {
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey,
type listBoardWebhooksApiV1BoardsBoardIdWebhooksGetResponse,
useCreateBoardWebhookApiV1BoardsBoardIdWebhooksPost,
useDeleteBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdDelete,
useListBoardWebhooksApiV1BoardsBoardIdWebhooksGet,
useUpdateBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdPatch,
} from "@/api/generated/board-webhooks/board-webhooks";
import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type {
AgentRead,
BoardGroupRead,
BoardWebhookRead,
BoardRead,
BoardUpdate,
} from "@/api/generated/model";
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import SearchableSelect from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { localDateInputToUtcIso, toLocalDateInput } from "@/lib/datetime";
import { Markdown } from "@/components/atoms/Markdown";
const slugify = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "board";
const LEAD_AGENT_VALUE = "__lead_agent__";
type WebhookCardProps = {
webhook: BoardWebhookRead;
agents: AgentRead[];
isLoading: boolean;
isWebhookCreating: boolean;
isDeletingWebhook: boolean;
isUpdatingWebhook: boolean;
copiedWebhookId: string | null;
onCopy: (webhook: BoardWebhookRead) => void;
onDelete: (webhookId: string) => void;
onViewPayloads: (webhookId: string) => void;
onUpdate: (
webhookId: string,
description: string,
agentId: string | null,
) => Promise<boolean>;
};
function WebhookCard({
webhook,
agents,
isLoading,
isWebhookCreating,
isDeletingWebhook,
isUpdatingWebhook,
copiedWebhookId,
onCopy,
onDelete,
onViewPayloads,
onUpdate,
}: WebhookCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [draftDescription, setDraftDescription] = useState(webhook.description);
const [draftAgentValue, setDraftAgentValue] = useState(
webhook.agent_id ?? LEAD_AGENT_VALUE,
);
const isBusy =
isLoading || isWebhookCreating || isDeletingWebhook || isUpdatingWebhook;
const trimmedDescription = draftDescription.trim();
const isDescriptionChanged =
trimmedDescription !== webhook.description.trim();
const isAgentChanged =
draftAgentValue !== (webhook.agent_id ?? LEAD_AGENT_VALUE);
const isChanged = isDescriptionChanged || isAgentChanged;
const mappedAgent = webhook.agent_id
? (agents.find((agent) => agent.id === webhook.agent_id) ?? null)
: null;
const handleSave = async () => {
if (!trimmedDescription) return;
if (!isChanged) {
setIsEditing(false);
return;
}
const saved = await onUpdate(
webhook.id,
trimmedDescription,
draftAgentValue === LEAD_AGENT_VALUE ? null : draftAgentValue,
);
if (saved) {
setIsEditing(false);
}
};
return (
<div
key={webhook.id}
className="space-y-3 rounded-lg border border-border px-4 py-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold text-foreground">
Webhook {webhook.id.slice(0, 8)}
</span>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
onClick={() => onCopy(webhook)}
disabled={isBusy}
>
{copiedWebhookId === webhook.id ? "Copied" : "Copy endpoint"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => onViewPayloads(webhook.id)}
disabled={isBusy}
>
View payloads
</Button>
{isEditing ? (
<>
<Button
type="button"
variant="ghost"
onClick={() => {
setDraftDescription(webhook.description);
setDraftAgentValue(webhook.agent_id ?? LEAD_AGENT_VALUE);
setIsEditing(false);
}}
disabled={isBusy}
>
Cancel
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isBusy || !trimmedDescription}
>
{isUpdatingWebhook ? "Saving…" : "Save"}
</Button>
</>
) : (
<>
<Button
type="button"
variant="ghost"
onClick={() => {
setDraftDescription(webhook.description);
setDraftAgentValue(webhook.agent_id ?? LEAD_AGENT_VALUE);
setIsEditing(true);
}}
disabled={isBusy}
>
Edit
</Button>
<Button
type="button"
variant="ghost"
onClick={() => onDelete(webhook.id)}
disabled={isBusy}
>
{isDeletingWebhook ? "Deleting…" : "Delete"}
</Button>
</>
)}
</div>
</div>
{isEditing ? (
<>
<Textarea
value={draftDescription}
onChange={(event) => setDraftDescription(event.target.value)}
placeholder="Describe exactly what the lead agent should do when payloads arrive."
className="min-h-[90px]"
disabled={isBusy}
/>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Agent</label>
<Select
value={draftAgentValue}
onValueChange={setDraftAgentValue}
disabled={isBusy}
>
<SelectTrigger>
<SelectValue placeholder="Lead agent (default fallback)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={LEAD_AGENT_VALUE}>
Lead agent (default fallback)
</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
{agent.is_board_lead ? " (lead)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
) : (
<>
<div className="text-sm text-muted-foreground">
<Markdown
content={webhook.description || ""}
variant="description"
/>
</div>
<p className="text-xs text-muted-foreground">
Recipient: {mappedAgent?.name ?? "Lead agent"}
</p>
</>
)}
<div className="rounded-md bg-muted px-3 py-2">
<code className="break-all text-xs text-muted-foreground">
{webhook.endpoint_url ?? webhook.endpoint_path}
</code>
</div>
</div>
);
}
export default function EditBoardPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const router = useRouter();
const searchParams = useSearchParams();
const params = useParams();
const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
const { isAdmin } = useOrganizationMembership(isSignedIn);
const mainRef = useRef<HTMLElement | null>(null);
const [board, setBoard] = useState<BoardRead | null>(null);
const [name, setName] = useState<string | undefined>(undefined);
const [description, setDescription] = useState<string | undefined>(undefined);
const [gatewayId, setGatewayId] = useState<string | undefined>(undefined);
const [boardGroupId, setBoardGroupId] = useState<string | undefined>(
undefined,
);
const [boardType, setBoardType] = useState<string | undefined>(undefined);
const [objective, setObjective] = useState<string | undefined>(undefined);
const [requireApprovalForDone, setRequireApprovalForDone] = useState<
boolean | undefined
>(undefined);
const [requireReviewBeforeDone, setRequireReviewBeforeDone] = useState<
boolean | undefined
>(undefined);
const [commentRequiredForReview, setCommentRequiredForReview] = useState<
boolean | undefined
>(undefined);
const [
blockStatusChangesWithPendingApproval,
setBlockStatusChangesWithPendingApproval,
] = useState<boolean | undefined>(undefined);
const [onlyLeadCanChangeStatus, setOnlyLeadCanChangeStatus] = useState<
boolean | undefined
>(undefined);
const [maxAgents, setMaxAgents] = useState<number | undefined>(undefined);
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
undefined,
);
const [targetDate, setTargetDate] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [metricsError, setMetricsError] = useState<string | null>(null);
const [webhookDescription, setWebhookDescription] = useState("");
const [webhookAgentValue, setWebhookAgentValue] = useState(LEAD_AGENT_VALUE);
const [webhookError, setWebhookError] = useState<string | null>(null);
const [copiedWebhookId, setCopiedWebhookId] = useState<string | null>(null);
const onboardingParam = searchParams.get("onboarding");
const searchParamsString = searchParams.toString();
const shouldAutoOpenOnboarding =
onboardingParam !== null &&
onboardingParam !== "" &&
onboardingParam !== "0" &&
onboardingParam.toLowerCase() !== "false";
const [isOnboardingOpen, setIsOnboardingOpen] = useState(
shouldAutoOpenOnboarding,
);
useEffect(() => {
if (!isOnboardingOpen) return;
const mainEl = mainRef.current;
const previousMainOverflow = mainEl?.style.overflow ?? "";
const previousHtmlOverflow = document.documentElement.style.overflow;
const previousBodyOverflow = document.body.style.overflow;
if (mainEl) {
mainEl.style.overflow = "hidden";
}
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
if (mainEl) {
mainEl.style.overflow = previousMainOverflow;
}
document.documentElement.style.overflow = previousHtmlOverflow;
document.body.style.overflow = previousBodyOverflow;
};
}, [isOnboardingOpen]);
useEffect(() => {
if (!boardId) return;
if (!shouldAutoOpenOnboarding) return;
// Remove the flag from the URL so refreshes don't constantly reopen it.
const nextParams = new URLSearchParams(searchParamsString);
nextParams.delete("onboarding");
const qs = nextParams.toString();
router.replace(
qs ? `/boards/${boardId}/edit?${qs}` : `/boards/${boardId}/edit`,
);
}, [boardId, router, searchParamsString, shouldAutoOpenOnboarding]);
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
retry: false,
},
});
const groupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
listBoardGroupsApiV1BoardGroupsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
retry: false,
},
});
const boardQuery = useGetBoardApiV1BoardsBoardIdGet<
getBoardApiV1BoardsBoardIdGetResponse,
ApiError
>(boardId ?? "", {
query: {
enabled: Boolean(isSignedIn && isAdmin && boardId),
refetchOnMount: "always",
retry: false,
},
});
const webhooksQuery = useListBoardWebhooksApiV1BoardsBoardIdWebhooksGet<
listBoardWebhooksApiV1BoardsBoardIdWebhooksGetResponse,
ApiError
>(
boardId ?? "",
{ limit: 50 },
{
query: {
enabled: Boolean(isSignedIn && isAdmin && boardId),
refetchOnMount: "always",
retry: false,
},
},
);
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>(
{ board_id: boardId ?? null, limit: 200 },
{
query: {
enabled: Boolean(isSignedIn && isAdmin && boardId),
refetchOnMount: "always",
retry: false,
},
},
);
const updateBoardMutation = useUpdateBoardApiV1BoardsBoardIdPatch<ApiError>({
mutation: {
onSuccess: (result) => {
if (result.status === 200) {
router.push(`/boards/${result.data.id}`);
}
},
onError: (err) => {
setError(err.message || "Something went wrong.");
},
},
});
const createWebhookMutation =
useCreateBoardWebhookApiV1BoardsBoardIdWebhooksPost<ApiError>({
mutation: {
onSuccess: async () => {
if (!boardId) return;
setWebhookDescription("");
setWebhookAgentValue(LEAD_AGENT_VALUE);
await queryClient.invalidateQueries({
queryKey:
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey(
boardId,
),
});
},
onError: (err) => {
setWebhookError(err.message || "Unable to create webhook.");
},
},
});
const deleteWebhookMutation =
useDeleteBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdDelete<ApiError>({
mutation: {
onSuccess: async () => {
if (!boardId) return;
await queryClient.invalidateQueries({
queryKey:
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey(
boardId,
),
});
},
onError: (err) => {
setWebhookError(err.message || "Unable to delete webhook.");
},
},
});
const updateWebhookMutation =
useUpdateBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdPatch<ApiError>({
mutation: {
onSuccess: async () => {
if (!boardId) return;
await queryClient.invalidateQueries({
queryKey:
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey(
boardId,
),
});
},
onError: (err) => {
setWebhookError(err.message || "Unable to update webhook.");
},
},
});
const gateways = useMemo(() => {
if (gatewaysQuery.data?.status !== 200) return [];
return gatewaysQuery.data.data.items ?? [];
}, [gatewaysQuery.data]);
const loadedBoard: BoardRead | null =
boardQuery.data?.status === 200 ? boardQuery.data.data : null;
const baseBoard = board ?? loadedBoard;
const resolvedName = name ?? baseBoard?.name ?? "";
const resolvedDescription = description ?? baseBoard?.description ?? "";
const resolvedGatewayId = gatewayId ?? baseBoard?.gateway_id ?? "";
const resolvedBoardGroupId =
boardGroupId ?? baseBoard?.board_group_id ?? "none";
const resolvedBoardType = boardType ?? baseBoard?.board_type ?? "goal";
const resolvedObjective = objective ?? baseBoard?.objective ?? "";
const isGoalFieldsRequired = resolvedBoardType === "goal";
const resolvedRequireApprovalForDone =
requireApprovalForDone ?? baseBoard?.require_approval_for_done ?? true;
const resolvedRequireReviewBeforeDone =
requireReviewBeforeDone ?? baseBoard?.require_review_before_done ?? false;
const resolvedCommentRequiredForReview =
commentRequiredForReview ?? baseBoard?.comment_required_for_review ?? false;
const resolvedBlockStatusChangesWithPendingApproval =
blockStatusChangesWithPendingApproval ??
baseBoard?.block_status_changes_with_pending_approval ??
false;
const resolvedOnlyLeadCanChangeStatus =
onlyLeadCanChangeStatus ?? baseBoard?.only_lead_can_change_status ?? false;
const resolvedMaxAgents = maxAgents ?? baseBoard?.max_agents ?? 1;
const resolvedSuccessMetrics =
successMetrics ??
(baseBoard?.success_metrics
? JSON.stringify(baseBoard.success_metrics, null, 2)
: "");
const resolvedTargetDate =
targetDate ?? toLocalDateInput(baseBoard?.target_date);
const displayGatewayId = resolvedGatewayId || gateways[0]?.id || "";
const isWebhookCreating = createWebhookMutation.isPending;
const deletingWebhookId =
deleteWebhookMutation.isPending && deleteWebhookMutation.variables
? deleteWebhookMutation.variables.webhookId
: null;
const updatingWebhookId =
updateWebhookMutation.isPending && updateWebhookMutation.variables
? updateWebhookMutation.variables.webhookId
: null;
const isWebhookBusy =
isWebhookCreating ||
deleteWebhookMutation.isPending ||
updateWebhookMutation.isPending;
const isLoading =
gatewaysQuery.isLoading ||
groupsQuery.isLoading ||
boardQuery.isLoading ||
updateBoardMutation.isPending;
const errorMessage =
error ??
gatewaysQuery.error?.message ??
groupsQuery.error?.message ??
boardQuery.error?.message ??
null;
const webhookErrorMessage =
webhookError ??
webhooksQuery.error?.message ??
agentsQuery.error?.message ??
null;
const isFormReady = Boolean(
resolvedName.trim() && resolvedDescription.trim() && displayGatewayId,
);
const gatewayOptions = useMemo(
() =>
gateways.map((gateway) => ({ value: gateway.id, label: gateway.name })),
[gateways],
);
const groups = useMemo<BoardGroupRead[]>(() => {
if (groupsQuery.data?.status !== 200) return [];
return groupsQuery.data.data.items ?? [];
}, [groupsQuery.data]);
const groupOptions = useMemo(
() => [
{ value: "none", label: "No group" },
...groups.map((group) => ({ value: group.id, label: group.name })),
],
[groups],
);
const webhookAgents = useMemo<AgentRead[]>(() => {
if (agentsQuery.data?.status !== 200) return [];
return agentsQuery.data.data.items ?? [];
}, [agentsQuery.data]);
const webhooks = useMemo<BoardWebhookRead[]>(() => {
if (webhooksQuery.data?.status !== 200) return [];
return webhooksQuery.data.data.items ?? [];
}, [webhooksQuery.data]);
const handleOnboardingConfirmed = (updated: BoardRead) => {
setBoard(updated);
setDescription(updated.description ?? "");
setBoardType(updated.board_type ?? "goal");
setObjective(updated.objective ?? "");
setRequireApprovalForDone(updated.require_approval_for_done ?? true);
setRequireReviewBeforeDone(updated.require_review_before_done ?? false);
setCommentRequiredForReview(updated.comment_required_for_review ?? false);
setBlockStatusChangesWithPendingApproval(
updated.block_status_changes_with_pending_approval ?? false,
);
setOnlyLeadCanChangeStatus(updated.only_lead_can_change_status ?? false);
setMaxAgents(updated.max_agents ?? 1);
setSuccessMetrics(
updated.success_metrics
? JSON.stringify(updated.success_metrics, null, 2)
: "",
);
setTargetDate(toLocalDateInput(updated.target_date));
setBoardGroupId(updated.board_group_id ?? "none");
setIsOnboardingOpen(false);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn || !boardId) return;
const trimmedName = resolvedName.trim();
if (!trimmedName) {
setError("Board name is required.");
return;
}
const resolvedGatewayId = displayGatewayId;
if (!resolvedGatewayId) {
setError("Select a gateway before saving.");
return;
}
const trimmedDescription = resolvedDescription.trim();
if (!trimmedDescription) {
setError("Board description is required.");
return;
}
if (!Number.isInteger(resolvedMaxAgents) || resolvedMaxAgents < 0) {
setError("Max worker agents must be a non-negative integer.");
return;
}
setError(null);
setMetricsError(null);
let parsedMetrics: Record<string, unknown> | null = null;
if (resolvedBoardType !== "general" && resolvedSuccessMetrics.trim()) {
try {
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<
string,
unknown
>;
} catch {
setMetricsError("Success metrics must be valid JSON.");
return;
}
}
const payload: BoardUpdate = {
name: trimmedName,
slug: slugify(trimmedName),
description: trimmedDescription,
gateway_id: resolvedGatewayId || null,
board_group_id:
resolvedBoardGroupId === "none" ? null : resolvedBoardGroupId,
board_type: resolvedBoardType,
objective:
resolvedBoardType === "general"
? null
: resolvedObjective.trim() || null,
require_approval_for_done: resolvedRequireApprovalForDone,
require_review_before_done: resolvedRequireReviewBeforeDone,
comment_required_for_review: resolvedCommentRequiredForReview,
block_status_changes_with_pending_approval:
resolvedBlockStatusChangesWithPendingApproval,
only_lead_can_change_status: resolvedOnlyLeadCanChangeStatus,
max_agents: resolvedMaxAgents,
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
target_date:
resolvedBoardType === "general"
? null
: localDateInputToUtcIso(resolvedTargetDate),
};
updateBoardMutation.mutate({ boardId, data: payload });
};
const handleCreateWebhook = () => {
if (!boardId) return;
const trimmedDescription = webhookDescription.trim();
if (!trimmedDescription) {
setWebhookError("Webhook instruction is required.");
return;
}
setWebhookError(null);
const mappedAgentId =
webhookAgentValue === LEAD_AGENT_VALUE ? null : webhookAgentValue;
createWebhookMutation.mutate({
boardId,
data: {
description: trimmedDescription,
enabled: true,
agent_id: mappedAgentId,
},
});
};
const handleDeleteWebhook = (webhookId: string) => {
if (!boardId) return;
if (deleteWebhookMutation.isPending) return;
setWebhookError(null);
deleteWebhookMutation.mutate({ boardId, webhookId });
};
const handleUpdateWebhook = async (
webhookId: string,
description: string,
agentId: string | null,
): Promise<boolean> => {
if (!boardId) return false;
if (updateWebhookMutation.isPending) return false;
const trimmedDescription = description.trim();
if (!trimmedDescription) {
setWebhookError("Webhook instruction is required.");
return false;
}
setWebhookError(null);
try {
await updateWebhookMutation.mutateAsync({
boardId,
webhookId,
data: {
description: trimmedDescription,
agent_id: agentId,
},
});
return true;
} catch {
return false;
}
};
const handleCopyWebhookEndpoint = async (webhook: BoardWebhookRead) => {
const endpoint = (webhook.endpoint_url ?? webhook.endpoint_path).trim();
try {
await navigator.clipboard.writeText(endpoint);
setCopiedWebhookId(webhook.id);
window.setTimeout(() => {
setCopiedWebhookId((current) =>
current === webhook.id ? null : current,
);
}, 1500);
} catch {
setWebhookError("Unable to copy webhook endpoint.");
}
};
const handleViewWebhookPayloads = (webhookId: string) => {
if (!boardId) return;
router.push(`/boards/${boardId}/webhooks/${webhookId}/payloads`);
};
return (
<>
<DashboardPageLayout
signedOut={{
message: "Sign in to edit boards.",
forceRedirectUrl: `/boards/${boardId}/edit`,
signUpForceRedirectUrl: `/boards/${boardId}/edit`,
}}
title="Edit board"
description="Update board settings and gateway."
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can edit board settings."
mainRef={mainRef}
>
<div className="space-y-6">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-border bg-card p-6 shadow-sm"
>
{resolvedBoardType !== "general" &&
baseBoard &&
!(baseBoard.goal_confirmed ?? false) ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-amber-900">
Goal needs confirmation
</p>
<p className="mt-1 text-xs text-amber-800/80">
Start onboarding to draft an objective and success metrics.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={() => setIsOnboardingOpen(true)}
disabled={isLoading || !baseBoard}
>
Start onboarding
</Button>
</div>
) : null}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !baseBoard}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="h-11 w-full px-3 py-2"
contentClassName="shadow-lg"
itemClassName="px-4 py-3"
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Board type
</label>
<Select value={resolvedBoardType} onValueChange={setBoardType}>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
<div className="space-y-2 pt-1">
<label className="text-sm font-medium text-foreground">
Max worker agents
</label>
<Input
type="number"
min={0}
step={1}
value={resolvedMaxAgents}
onChange={(event) => {
const next = Number.parseInt(event.target.value, 10);
if (Number.isNaN(next)) {
setMaxAgents(0);
return;
}
setMaxAgents(Math.max(0, next));
}}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Board group
</label>
<SearchableSelect
ariaLabel="Select board group"
value={resolvedBoardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
searchPlaceholder="Search groups..."
emptyMessage="No groups found."
triggerClassName="h-11 w-full px-3 py-2"
contentClassName="shadow-lg"
itemClassName="px-4 py-3"
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Boards in the same group can share cross-board context for
agents.
</p>
</div>
{resolvedBoardType !== "general" ? (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Target date
</label>
<Input
type="date"
value={resolvedTargetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Description <span className="text-red-500">*</span>
</label>
<Textarea
value={resolvedDescription}
onChange={(event) => setDescription(event.target.value)}
placeholder="What context should the lead agent know?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
{resolvedBoardType !== "general" ? (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Objective
{isGoalFieldsRequired && (
<span className="text-red-500"> *</span>
)}
</label>
<Textarea
value={resolvedObjective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Success metrics (JSON)
{isGoalFieldsRequired && (
<span className="text-red-500"> *</span>
)}
</label>
<Textarea
value={resolvedSuccessMetrics}
onChange={(event) => setSuccessMetrics(event.target.value)}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
</>
) : null}
<section className="space-y-3 border-t border-border pt-4">
<div>
<h2 className="text-base font-semibold text-foreground">
Rules
</h2>
<p className="text-xs text-muted-foreground">
Configure board-level workflow enforcement.
</p>
</div>
<div className="flex items-start gap-3 rounded-lg border border-border px-3 py-3">
<button
type="button"
role="switch"
aria-checked={resolvedRequireApprovalForDone}
aria-label="Require approval"
onClick={() =>
setRequireApprovalForDone(!resolvedRequireApprovalForDone)
}
disabled={isLoading}
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
resolvedRequireApprovalForDone
? "border-emerald-600 bg-emerald-600"
: "border-input bg-border"
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-card shadow-sm transition ${
resolvedRequireApprovalForDone
? "translate-x-5"
: "translate-x-0.5"
}`}
/>
</button>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">
Require approval
</span>
<span className="block text-xs text-muted-foreground">
Require at least one linked approval in{" "}
<code>approved</code> state before a task can be marked{" "}
<code>done</code>.
</span>
</span>
</div>
<div className="flex items-start gap-3 rounded-lg border border-border px-3 py-3">
<button
type="button"
role="switch"
aria-checked={resolvedRequireReviewBeforeDone}
aria-label="Require review before done"
onClick={() =>
setRequireReviewBeforeDone(!resolvedRequireReviewBeforeDone)
}
disabled={isLoading}
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
resolvedRequireReviewBeforeDone
? "border-emerald-600 bg-emerald-600"
: "border-input bg-border"
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-card shadow-sm transition ${
resolvedRequireReviewBeforeDone
? "translate-x-5"
: "translate-x-0.5"
}`}
/>
</button>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">
Require review before done
</span>
<span className="block text-xs text-muted-foreground">
Tasks must move to <code>review</code> before they can be
marked <code>done</code>.
</span>
</span>
</div>
<div className="flex items-start gap-3 rounded-lg border border-border px-3 py-3">
<button
type="button"
role="switch"
aria-checked={resolvedCommentRequiredForReview}
aria-label="Require comment for review"
onClick={() =>
setCommentRequiredForReview(
!resolvedCommentRequiredForReview,
)
}
disabled={isLoading}
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
resolvedCommentRequiredForReview
? "border-emerald-600 bg-emerald-600"
: "border-input bg-border"
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-card shadow-sm transition ${
resolvedCommentRequiredForReview
? "translate-x-5"
: "translate-x-0.5"
}`}
/>
</button>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">
Require comment for review
</span>
<span className="block text-xs text-muted-foreground">
Require a task comment when moving status to{" "}
<code>review</code>.
</span>
</span>
</div>
<div className="flex items-start gap-3 rounded-lg border border-border px-3 py-3">
<button
type="button"
role="switch"
aria-checked={resolvedBlockStatusChangesWithPendingApproval}
aria-label="Block status changes with pending approval"
onClick={() =>
setBlockStatusChangesWithPendingApproval(
!resolvedBlockStatusChangesWithPendingApproval,
)
}
disabled={isLoading}
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
resolvedBlockStatusChangesWithPendingApproval
? "border-emerald-600 bg-emerald-600"
: "border-input bg-border"
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-card shadow-sm transition ${
resolvedBlockStatusChangesWithPendingApproval
? "translate-x-5"
: "translate-x-0.5"
}`}
/>
</button>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">
Block status changes with pending approval
</span>
<span className="block text-xs text-muted-foreground">
Prevent status transitions while any linked approval is in{" "}
<code>pending</code> state.
</span>
</span>
</div>
<div className="flex items-start gap-3 rounded-lg border border-border px-3 py-3">
<button
type="button"
role="switch"
aria-checked={resolvedOnlyLeadCanChangeStatus}
aria-label="Only lead can change status"
onClick={() =>
setOnlyLeadCanChangeStatus(!resolvedOnlyLeadCanChangeStatus)
}
disabled={isLoading}
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
resolvedOnlyLeadCanChangeStatus
? "border-emerald-600 bg-emerald-600"
: "border-input bg-border"
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-card shadow-sm transition ${
resolvedOnlyLeadCanChangeStatus
? "translate-x-5"
: "translate-x-0.5"
}`}
/>
</button>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">
Only lead can change status
</span>
<span className="block text-xs text-muted-foreground">
Restrict status changes to the board lead.
</span>
</span>
</div>
</section>
{gateways.length === 0 ? (
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-sm text-muted-foreground">
<p>
No gateways available. Create one in Gateways to continue.
</p>
</div>
) : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || !baseBoard || !isFormReady}
>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
<section className="space-y-4 border-t border-border pt-4">
<div>
<h2 className="text-base font-semibold text-foreground">
Webhooks
</h2>
<p className="text-xs text-muted-foreground">
Add inbound webhook endpoints so the lead agent can react to
external events.
</p>
</div>
<div className="space-y-3 rounded-lg border border-border px-4 py-4">
<label className="text-sm font-medium text-foreground">
Lead agent instruction
</label>
<Textarea
value={webhookDescription}
onChange={(event) =>
setWebhookDescription(event.target.value)
}
placeholder="Describe exactly what the lead agent should do when payloads arrive."
className="min-h-[90px]"
disabled={isLoading || isWebhookBusy}
/>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Agent
</label>
<Select
value={webhookAgentValue}
onValueChange={setWebhookAgentValue}
disabled={isLoading || isWebhookBusy}
>
<SelectTrigger>
<SelectValue placeholder="Lead agent (default fallback)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={LEAD_AGENT_VALUE}>
Lead agent (default fallback)
</SelectItem>
{webhookAgents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
{agent.is_board_lead ? " (lead)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={handleCreateWebhook}
disabled={
isLoading ||
isWebhookBusy ||
!baseBoard ||
!webhookDescription.trim()
}
>
{createWebhookMutation.isPending
? "Creating webhook…"
: "Create webhook"}
</Button>
</div>
</div>
{webhookErrorMessage ? (
<p className="text-sm text-red-500">{webhookErrorMessage}</p>
) : null}
{webhooksQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading webhooks</p>
) : null}
{!webhooksQuery.isLoading && webhooks.length === 0 ? (
<p className="rounded-lg border border-dashed border-input px-4 py-3 text-sm text-muted-foreground">
No webhooks configured yet.
</p>
) : null}
<div className="space-y-3">
{webhooks.map((webhook) => {
const isDeletingWebhook = deletingWebhookId === webhook.id;
const isUpdatingWebhook = updatingWebhookId === webhook.id;
return (
<WebhookCard
key={webhook.id}
webhook={webhook}
agents={webhookAgents}
isLoading={isLoading}
isWebhookCreating={isWebhookCreating}
isDeletingWebhook={isDeletingWebhook}
isUpdatingWebhook={isUpdatingWebhook}
copiedWebhookId={copiedWebhookId}
onCopy={handleCopyWebhookEndpoint}
onDelete={handleDeleteWebhook}
onViewPayloads={handleViewWebhookPayloads}
onUpdate={handleUpdateWebhook}
/>
);
})}
</div>
</section>
</form>
</div>
</DashboardPageLayout>
<Dialog open={isOnboardingOpen} onOpenChange={setIsOnboardingOpen}>
<DialogContent
aria-label="Board onboarding"
onPointerDownOutside={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
>
<div className="flex">
<DialogClose asChild>
<button
type="button"
className="sticky top-4 z-10 ml-auto rounded-lg border border-border bg-[color:var(--surface)] p-2 text-muted-foreground transition hover:bg-muted"
aria-label="Close onboarding"
>
<X className="h-4 w-4" />
</button>
</DialogClose>
</div>
{boardId ? (
<BoardOnboardingChat
boardId={boardId}
onConfirmed={handleOnboardingConfirmed}
/>
) : (
<div className="rounded-lg border border-border bg-muted p-3 text-sm text-muted-foreground">
Unable to start onboarding.
</div>
)}
</DialogContent>
</Dialog>
</>
);
}