feat: add validation for minimum length on various fields and update type definitions

This commit is contained in:
Abhimanyu Saharan
2026-02-06 16:12:04 +05:30
parent ca614328ac
commit d86fe0a7a6
157 changed files with 12340 additions and 2977 deletions

View File

@@ -1,10 +1,21 @@
"use client";
import { useEffect, useState } from "react";
import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { ApiError } from "@/api/mutator";
import {
type getAgentApiV1AgentsAgentIdGetResponse,
useGetAgentApiV1AgentsAgentIdGet,
useUpdateAgentApiV1AgentsAgentIdPatch,
} from "@/api/generated/agents/agents";
import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import type { AgentRead, AgentUpdate, BoardRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
@@ -20,34 +31,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { getApiBaseUrl } from "@/lib/api-base";
import {
DEFAULT_IDENTITY_PROFILE,
DEFAULT_SOUL_TEMPLATE,
} from "@/lib/agent-templates";
const apiBase = getApiBaseUrl();
type Agent = {
id: string;
name: string;
board_id?: string | null;
is_gateway_main?: boolean;
heartbeat_config?: {
every?: string;
target?: string;
} | null;
identity_profile?: IdentityProfile | null;
identity_template?: string | null;
soul_template?: string | null;
};
type Board = {
id: string;
name: string;
slug: string;
};
type IdentityProfile = {
role: string;
communication_style: string;
@@ -72,7 +60,7 @@ const HEARTBEAT_TARGET_OPTIONS: SearchableSelectOption[] = [
{ value: "last", label: "Last channel" },
];
const getBoardOptions = (boards: Board[]): SearchableSelectOption[] =>
const getBoardOptions = (boards: BoardRead[]): SearchableSelectOption[] =>
boards.map((board) => ({
value: board.id,
label: board.name,
@@ -100,157 +88,169 @@ const withIdentityDefaults = (
});
export default function EditAgentPage() {
const { getToken, isSignedIn } = useAuth();
const { isSignedIn } = useAuth();
const router = useRouter();
const params = useParams();
const agentIdParam = params?.agentId;
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
const [agent, setAgent] = useState<Agent | null>(null);
const [name, setName] = useState("");
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState("");
const [boardTouched, setBoardTouched] = useState(false);
const [isGatewayMain, setIsGatewayMain] = useState(false);
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
const [identityProfile, setIdentityProfile] = useState<IdentityProfile>({
...DEFAULT_IDENTITY_PROFILE,
});
const [soulTemplate, setSoulTemplate] = useState(DEFAULT_SOUL_TEMPLATE);
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState<string | undefined>(undefined);
const [boardId, setBoardId] = useState<string | undefined>(undefined);
const [isGatewayMain, setIsGatewayMain] = useState<boolean | undefined>(
undefined,
);
const [heartbeatEvery, setHeartbeatEvery] = useState<string | undefined>(
undefined,
);
const [heartbeatTarget, setHeartbeatTarget] = useState<string | undefined>(
undefined,
);
const [identityProfile, setIdentityProfile] = useState<
IdentityProfile | undefined
>(undefined);
const [soulTemplate, setSoulTemplate] = useState<string | undefined>(
undefined,
);
const [error, setError] = useState<string | null>(null);
const loadBoards = async () => {
if (!isSignedIn) return;
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const agentQuery = useGetAgentApiV1AgentsAgentIdGet<
getAgentApiV1AgentsAgentIdGetResponse,
ApiError
>(agentId ?? "", {
query: {
enabled: Boolean(isSignedIn && agentId),
refetchOnMount: "always",
retry: false,
},
});
const updateMutation = useUpdateAgentApiV1AgentsAgentIdPatch<ApiError>({
mutation: {
onSuccess: () => {
if (agentId) {
router.push(`/agents/${agentId}`);
}
},
onError: (err) => {
setError(err.message || "Something went wrong.");
},
},
});
const boards = boardsQuery.data?.status === 200 ? boardsQuery.data.data : [];
const loadedAgent: AgentRead | null =
agentQuery.data?.status === 200 ? agentQuery.data.data : null;
const loadedHeartbeat = useMemo(() => {
const heartbeat = loadedAgent?.heartbeat_config;
if (heartbeat && typeof heartbeat === "object") {
const record = heartbeat as Record<string, unknown>;
const every = record.every;
const target = record.target;
return {
every: typeof every === "string" && every.trim() ? every : "10m",
target: typeof target === "string" && target.trim() ? target : "none",
};
}
return { every: "10m", target: "none" };
}, [loadedAgent?.heartbeat_config]);
const loadedIdentityProfile = useMemo(() => {
const identity = loadedAgent?.identity_profile;
if (identity && typeof identity === "object") {
const record = identity as Record<string, unknown>;
return withIdentityDefaults({
role: typeof record.role === "string" ? record.role : undefined,
communication_style:
typeof record.communication_style === "string"
? record.communication_style
: undefined,
emoji: typeof record.emoji === "string" ? record.emoji : undefined,
});
if (!response.ok) {
throw new Error("Unable to load boards.");
}
const data = (await response.json()) as Board[];
setBoards(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
}
};
return withIdentityDefaults(null);
}, [loadedAgent?.identity_profile]);
const loadAgent = async () => {
if (!isSignedIn || !agentId) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents/${agentId}`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load agent.");
}
const data = (await response.json()) as Agent;
setAgent(data);
setName(data.name);
setIsGatewayMain(Boolean(data.is_gateway_main));
if (!data.is_gateway_main && data.board_id) {
setBoardId(data.board_id);
} else {
setBoardId("");
}
setBoardTouched(false);
if (data.heartbeat_config?.every) {
setHeartbeatEvery(data.heartbeat_config.every);
}
if (data.heartbeat_config?.target) {
setHeartbeatTarget(data.heartbeat_config.target);
}
setIdentityProfile(withIdentityDefaults(data.identity_profile));
setSoulTemplate(data.soul_template?.trim() || DEFAULT_SOUL_TEMPLATE);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
const loadedSoulTemplate = useMemo(() => {
return loadedAgent?.soul_template?.trim() || DEFAULT_SOUL_TEMPLATE;
}, [loadedAgent?.soul_template]);
useEffect(() => {
loadBoards();
loadAgent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn, agentId]);
const isLoading =
boardsQuery.isLoading || agentQuery.isLoading || updateMutation.isPending;
const errorMessage =
error ?? agentQuery.error?.message ?? boardsQuery.error?.message ?? null;
useEffect(() => {
if (boardTouched || boardId || isGatewayMain) return;
if (agent?.board_id) {
setBoardId(agent.board_id);
return;
}
if (boards.length > 0) {
setBoardId(boards[0].id);
}
}, [agent, boards, boardId, isGatewayMain, boardTouched]);
const resolvedName = name ?? loadedAgent?.name ?? "";
const resolvedIsGatewayMain =
isGatewayMain ?? Boolean(loadedAgent?.is_gateway_main);
const resolvedHeartbeatEvery = heartbeatEvery ?? loadedHeartbeat.every;
const resolvedHeartbeatTarget = heartbeatTarget ?? loadedHeartbeat.target;
const resolvedIdentityProfile = identityProfile ?? loadedIdentityProfile;
const resolvedSoulTemplate = soulTemplate ?? loadedSoulTemplate;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const resolvedBoardId = useMemo(() => {
if (resolvedIsGatewayMain) return boardId ?? "";
return boardId ?? loadedAgent?.board_id ?? boards[0]?.id ?? "";
}, [boardId, boards, loadedAgent?.board_id, resolvedIsGatewayMain]);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn || !agentId) return;
const trimmed = name.trim();
if (!isSignedIn || !agentId || !loadedAgent) return;
const trimmed = resolvedName.trim();
if (!trimmed) {
setError("Agent name is required.");
return;
}
if (!isGatewayMain && !boardId) {
if (!resolvedIsGatewayMain && !resolvedBoardId) {
setError("Select a board or mark this agent as the gateway main.");
return;
}
if (isGatewayMain && !boardId && !agent?.is_gateway_main && !agent?.board_id) {
if (
resolvedIsGatewayMain &&
!resolvedBoardId &&
!loadedAgent.is_gateway_main &&
!loadedAgent.board_id
) {
setError(
"Select a board once so we can resolve the gateway main session key."
);
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const payload: Record<string, unknown> = {
name: trimmed,
heartbeat_config: {
every: heartbeatEvery.trim() || "10m",
target: heartbeatTarget,
},
identity_profile: normalizeIdentityProfile(identityProfile),
soul_template: soulTemplate.trim() || null,
};
if (!isGatewayMain) {
payload.board_id = boardId || null;
} else if (boardId) {
payload.board_id = boardId;
}
if (agent?.is_gateway_main !== isGatewayMain) {
payload.is_gateway_main = isGatewayMain;
}
const response = await fetch(
`${apiBase}/api/v1/agents/${agentId}?force=true`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify(payload),
}
);
if (!response.ok) {
throw new Error("Unable to update agent.");
}
router.push(`/agents/${agentId}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
const payload: AgentUpdate = {
name: trimmed,
heartbeat_config: {
every: resolvedHeartbeatEvery.trim() || "10m",
target: resolvedHeartbeatTarget,
} as unknown as Record<string, unknown>,
identity_profile: normalizeIdentityProfile(resolvedIdentityProfile) as unknown as Record<
string,
unknown
> | null,
soul_template: resolvedSoulTemplate.trim() || null,
};
if (!resolvedIsGatewayMain) {
payload.board_id = resolvedBoardId || null;
} else if (resolvedBoardId) {
payload.board_id = resolvedBoardId;
}
if (Boolean(loadedAgent.is_gateway_main) !== resolvedIsGatewayMain) {
payload.is_gateway_main = resolvedIsGatewayMain;
}
updateMutation.mutate({ agentId, params: { force: true }, data: payload });
};
return (
@@ -275,7 +275,7 @@ export default function EditAgentPage() {
<div className="border-b border-slate-200 bg-white px-8 py-6">
<div>
<h1 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
{agent?.name ?? "Edit agent"}
{resolvedName.trim() ? resolvedName : loadedAgent?.name ?? "Edit agent"}
</h1>
<p className="mt-1 text-sm text-slate-500">
Status is controlled by agent heartbeat.
@@ -299,7 +299,7 @@ export default function EditAgentPage() {
Agent name <span className="text-red-500">*</span>
</label>
<Input
value={name}
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deploy bot"
disabled={isLoading}
@@ -310,12 +310,12 @@ export default function EditAgentPage() {
Role
</label>
<Input
value={identityProfile.role}
value={resolvedIdentityProfile.role}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
setIdentityProfile({
...resolvedIdentityProfile,
role: event.target.value,
}))
})
}
placeholder="e.g. Founder, Social Media Manager"
disabled={isLoading}
@@ -327,7 +327,7 @@ export default function EditAgentPage() {
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-900">
Board
{isGatewayMain ? (
{resolvedIsGatewayMain ? (
<span className="ml-2 text-xs font-normal text-slate-500">
optional
</span>
@@ -335,12 +335,11 @@ export default function EditAgentPage() {
<span className="text-red-500"> *</span>
)}
</label>
{boardId ? (
{resolvedBoardId ? (
<button
type="button"
className="text-xs font-medium text-slate-600 hover:text-slate-900"
onClick={() => {
setBoardTouched(true);
setBoardId("");
}}
disabled={isLoading}
@@ -351,13 +350,10 @@ export default function EditAgentPage() {
</div>
<SearchableSelect
ariaLabel="Select board"
value={boardId}
onValueChange={(value) => {
setBoardTouched(true);
setBoardId(value);
}}
value={resolvedBoardId}
onValueChange={(value) => setBoardId(value)}
options={getBoardOptions(boards)}
placeholder={isGatewayMain ? "No board (main agent)" : "Select board"}
placeholder={resolvedIsGatewayMain ? "No board (main agent)" : "Select board"}
searchPlaceholder="Search boards..."
emptyMessage="No matching boards."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
@@ -365,7 +361,7 @@ export default function EditAgentPage() {
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={boards.length === 0}
/>
{isGatewayMain ? (
{resolvedIsGatewayMain ? (
<p className="text-xs text-slate-500">
Main agents are not attached to a board. If a board is
selected, it is only used to resolve the gateway main
@@ -382,12 +378,12 @@ export default function EditAgentPage() {
Emoji
</label>
<Select
value={identityProfile.emoji}
value={resolvedIdentityProfile.emoji}
onValueChange={(value) =>
setIdentityProfile((current) => ({
...current,
setIdentityProfile({
...resolvedIdentityProfile,
emoji: value,
}))
})
}
disabled={isLoading}
>
@@ -410,7 +406,7 @@ export default function EditAgentPage() {
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-200"
checked={isGatewayMain}
checked={resolvedIsGatewayMain}
onChange={(event) => setIsGatewayMain(event.target.checked)}
disabled={isLoading}
/>
@@ -437,12 +433,12 @@ export default function EditAgentPage() {
Communication style
</label>
<Input
value={identityProfile.communication_style}
value={resolvedIdentityProfile.communication_style}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
setIdentityProfile({
...resolvedIdentityProfile,
communication_style: event.target.value,
}))
})
}
disabled={isLoading}
/>
@@ -452,7 +448,7 @@ export default function EditAgentPage() {
Soul template
</label>
<Textarea
value={soulTemplate}
value={resolvedSoulTemplate}
onChange={(event) => setSoulTemplate(event.target.value)}
rows={10}
disabled={isLoading}
@@ -471,7 +467,7 @@ export default function EditAgentPage() {
Interval
</label>
<Input
value={heartbeatEvery}
value={resolvedHeartbeatEvery}
onChange={(event) => setHeartbeatEvery(event.target.value)}
placeholder="e.g. 10m"
disabled={isLoading}
@@ -486,7 +482,7 @@ export default function EditAgentPage() {
</label>
<SearchableSelect
ariaLabel="Select heartbeat target"
value={heartbeatTarget}
value={resolvedHeartbeatTarget}
onValueChange={setHeartbeatTarget}
options={HEARTBEAT_TARGET_OPTIONS}
placeholder="Select target"
@@ -501,9 +497,9 @@ export default function EditAgentPage() {
</div>
</div>
{error ? (
{errorMessage ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
{error}
{errorMessage}
</div>
) : null}

View File

@@ -1,11 +1,26 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { ApiError } from "@/api/mutator";
import {
type getAgentApiV1AgentsAgentIdGetResponse,
useDeleteAgentApiV1AgentsAgentIdDelete,
useGetAgentApiV1AgentsAgentIdGet,
} from "@/api/generated/agents/agents";
import {
type listActivityApiV1ActivityGetResponse,
useListActivityApiV1ActivityGet,
} from "@/api/generated/activity/activity";
import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import type { ActivityEventRead, AgentRead, BoardRead } from "@/api/generated/model";
import { StatusPill } from "@/components/atoms/StatusPill";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -18,36 +33,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase = getApiBaseUrl();
type Agent = {
id: string;
name: string;
status: string;
openclaw_session_id?: string | null;
last_seen_at: string;
created_at: string;
updated_at: string;
board_id?: string | null;
is_board_lead?: boolean;
is_gateway_main?: boolean;
};
type Board = {
id: string;
name: string;
slug: string;
};
type ActivityEvent = {
id: string;
event_type: string;
message?: string | null;
agent_id?: string | null;
created_at: string;
};
const parseTimestamp = (value?: string | null) => {
if (!value) return null;
@@ -83,95 +68,96 @@ const formatRelative = (value?: string | null) => {
};
export default function AgentDetailPage() {
const { getToken, isSignedIn } = useAuth();
const { isSignedIn } = useAuth();
const router = useRouter();
const params = useParams();
const agentIdParam = params?.agentId;
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
const [agent, setAgent] = useState<Agent | null>(null);
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [boards, setBoards] = useState<Board[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const agentQuery = useGetAgentApiV1AgentsAgentIdGet<
getAgentApiV1AgentsAgentIdGetResponse,
ApiError
>(agentId ?? "", {
query: {
enabled: Boolean(isSignedIn && agentId),
refetchInterval: 30_000,
refetchOnMount: "always",
retry: false,
},
});
const activityQuery = useListActivityApiV1ActivityGet<
listActivityApiV1ActivityGetResponse,
ApiError
>(
{ limit: 200 },
{
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
retry: false,
},
},
);
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 60_000,
refetchOnMount: "always",
retry: false,
},
});
const agent: AgentRead | null =
agentQuery.data?.status === 200 ? agentQuery.data.data : null;
const events: ActivityEventRead[] =
activityQuery.data?.status === 200 ? activityQuery.data.data : [];
const boards: BoardRead[] =
boardsQuery.data?.status === 200 ? boardsQuery.data.data : [];
const agentEvents = useMemo(() => {
if (!agent) return [];
return events.filter((event) => event.agent_id === agent.id);
}, [events, agent]);
const linkedBoard = useMemo(() => {
if (!agent?.board_id || agent?.is_gateway_main) return null;
return boards.find((board) => board.id === agent.board_id) ?? null;
}, [boards, agent?.board_id, agent?.is_gateway_main]);
const linkedBoard =
!agent?.board_id || agent?.is_gateway_main
? null
: boards.find((board) => board.id === agent.board_id) ?? null;
const deleteMutation = useDeleteAgentApiV1AgentsAgentIdDelete<ApiError>({
mutation: {
onSuccess: () => {
setDeleteOpen(false);
router.push("/agents");
},
onError: (err) => {
setDeleteError(err.message || "Something went wrong.");
},
},
});
const loadAgent = async () => {
if (!isSignedIn || !agentId) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const [agentResponse, activityResponse, boardsResponse] = await Promise.all([
fetch(`${apiBase}/api/v1/agents/${agentId}`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
fetch(`${apiBase}/api/v1/activity?limit=200`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
fetch(`${apiBase}/api/v1/boards`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
]);
if (!agentResponse.ok) {
throw new Error("Unable to load agent.");
}
if (!activityResponse.ok) {
throw new Error("Unable to load activity.");
}
if (!boardsResponse.ok) {
throw new Error("Unable to load boards.");
}
const agentData = (await agentResponse.json()) as Agent;
const eventsData = (await activityResponse.json()) as ActivityEvent[];
const boardsData = (await boardsResponse.json()) as Board[];
setAgent(agentData);
setEvents(eventsData);
setBoards(boardsData);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
const isLoading =
agentQuery.isLoading || activityQuery.isLoading || boardsQuery.isLoading;
const error =
agentQuery.error?.message ??
activityQuery.error?.message ??
boardsQuery.error?.message ??
null;
useEffect(() => {
loadAgent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn, agentId]);
const isDeleting = deleteMutation.isPending;
const agentStatus = agent?.status ?? "unknown";
const handleDelete = async () => {
if (!agent || !isSignedIn) return;
setIsDeleting(true);
const handleDelete = () => {
if (!agentId || !isSignedIn) return;
setDeleteError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents/${agent.id}`, {
method: "DELETE",
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to delete agent.");
}
router.push("/agents");
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsDeleting(false);
}
deleteMutation.mutate({ agentId });
};
return (
@@ -247,7 +233,7 @@ export default function AgentDetailPage() {
{agent.name}
</p>
</div>
<StatusPill status={agent.status} />
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
@@ -316,7 +302,7 @@ export default function AgentDetailPage() {
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Health
</p>
<StatusPill status={agent.status} />
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-3 text-sm text-muted">
<div className="flex items-center justify-between">
@@ -329,7 +315,7 @@ export default function AgentDetailPage() {
</div>
<div className="flex items-center justify-between">
<span>Status</span>
<span className="text-strong">{agent.status}</span>
<span className="text-strong">{agentStatus}</span>
</div>
</div>
</div>

View File

@@ -1,10 +1,17 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { ApiError } from "@/api/mutator";
import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents";
import type { BoardRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
@@ -20,25 +27,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { getApiBaseUrl } from "@/lib/api-base";
import {
DEFAULT_IDENTITY_PROFILE,
DEFAULT_SOUL_TEMPLATE,
} from "@/lib/agent-templates";
const apiBase = getApiBaseUrl();
type Agent = {
id: string;
name: string;
};
type Board = {
id: string;
name: string;
slug: string;
};
type IdentityProfile = {
role: string;
communication_style: string;
@@ -63,7 +56,7 @@ const HEARTBEAT_TARGET_OPTIONS: SearchableSelectOption[] = [
{ value: "last", label: "Last channel" },
];
const getBoardOptions = (boards: Board[]): SearchableSelectOption[] =>
const getBoardOptions = (boards: BoardRead[]): SearchableSelectOption[] =>
boards.map((board) => ({
value: board.id,
label: board.name,
@@ -83,10 +76,9 @@ const normalizeIdentityProfile = (
export default function NewAgentPage() {
const router = useRouter();
const { getToken, isSignedIn } = useAuth();
const { isSignedIn } = useAuth();
const [name, setName] = useState("");
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState<string>("");
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
@@ -94,35 +86,37 @@ export default function NewAgentPage() {
...DEFAULT_IDENTITY_PROFILE,
});
const [soulTemplate, setSoulTemplate] = useState(DEFAULT_SOUL_TEMPLATE);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadBoards = async () => {
if (!isSignedIn) return;
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load boards.");
}
const data = (await response.json()) as Board[];
setBoards(data);
if (!boardId && data.length > 0) {
setBoardId(data[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
}
};
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
},
});
useEffect(() => {
loadBoards();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const createAgentMutation = useCreateAgentApiV1AgentsPost<ApiError>({
mutation: {
onSuccess: (result) => {
if (result.status === 200) {
router.push(`/agents/${result.data.id}`);
}
},
onError: (err) => {
setError(err.message || "Something went wrong.");
},
},
});
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const boards = boardsQuery.data?.status === 200 ? boardsQuery.data.data : [];
const displayBoardId = boardId || boards[0]?.id || "";
const isLoading = boardsQuery.isLoading || createAgentMutation.isPending;
const errorMessage = error ?? boardsQuery.error?.message ?? null;
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn) return;
const trimmed = name.trim();
@@ -130,41 +124,27 @@ export default function NewAgentPage() {
setError("Agent name is required.");
return;
}
if (!boardId) {
const resolvedBoardId = displayBoardId;
if (!resolvedBoardId) {
setError("Select a board before creating an agent.");
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
createAgentMutation.mutate({
data: {
name: trimmed,
board_id: resolvedBoardId,
heartbeat_config: {
every: heartbeatEvery.trim() || "10m",
target: heartbeatTarget,
},
body: JSON.stringify({
name: trimmed,
board_id: boardId,
heartbeat_config: {
every: heartbeatEvery.trim() || "10m",
target: heartbeatTarget,
},
identity_profile: normalizeIdentityProfile(identityProfile),
soul_template: soulTemplate.trim() || null,
}),
});
if (!response.ok) {
throw new Error("Unable to create agent.");
}
const created = (await response.json()) as Agent;
router.push(`/agents/${created.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
identity_profile: normalizeIdentityProfile(identityProfile) as unknown as Record<
string,
unknown
> | null,
soul_template: soulTemplate.trim() || null,
},
});
};
return (
@@ -243,7 +223,7 @@ export default function NewAgentPage() {
</label>
<SearchableSelect
ariaLabel="Select board"
value={boardId}
value={displayBoardId}
onValueChange={setBoardId}
options={getBoardOptions(boards)}
placeholder="Select board"
@@ -364,9 +344,9 @@ export default function NewAgentPage() {
</div>
</div>
{error ? (
{errorMessage ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
{error}
{errorMessage}
</div>
) : null}

View File

@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import {
type ColumnDef,
type SortingState,
@@ -27,25 +27,20 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { apiRequest, useAuthedMutation, useAuthedQuery } from "@/lib/api-query";
type Agent = {
id: string;
name: string;
status: string;
openclaw_session_id?: string | null;
last_seen_at: string;
created_at: string;
updated_at: string;
board_id?: string | null;
is_board_lead?: boolean;
};
type Board = {
id: string;
name: string;
slug: string;
};
import { ApiError } from "@/api/mutator";
import {
type listAgentsApiV1AgentsGetResponse,
getListAgentsApiV1AgentsGetQueryKey,
useDeleteAgentApiV1AgentsAgentIdDelete,
useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents";
import {
type listBoardsApiV1BoardsGetResponse,
getListBoardsApiV1BoardsGetQueryKey,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import type { AgentRead, BoardRead } from "@/api/generated/model";
const parseTimestamp = (value?: string | null) => {
if (!value) return null;
@@ -87,6 +82,7 @@ const truncate = (value?: string | null, max = 18) => {
};
export default function AgentsPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const router = useRouter();
@@ -94,47 +90,72 @@ export default function AgentsPage() {
{ id: "name", desc: false },
]);
const [deleteTarget, setDeleteTarget] = useState<Agent | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AgentRead | null>(null);
const boardsQuery = useAuthedQuery<Board[]>(["boards"], "/api/v1/boards", {
refetchInterval: 30_000,
refetchOnMount: "always",
});
const agentsQuery = useAuthedQuery<Agent[]>(["agents"], "/api/v1/agents", {
refetchInterval: 15_000,
refetchOnMount: "always",
const boardsKey = getListBoardsApiV1BoardsGetQueryKey();
const agentsKey = getListAgentsApiV1AgentsGetQueryKey();
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
refetchOnMount: "always",
},
});
const boards = useMemo(() => boardsQuery.data ?? [], [boardsQuery.data]);
const agents = useMemo(() => agentsQuery.data ?? [], [agentsQuery.data]);
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 15_000,
refetchOnMount: "always",
},
});
const deleteMutation = useAuthedMutation<void, Agent, { previous?: Agent[] }>(
async (agent, token) =>
apiRequest(`/api/v1/agents/${agent.id}`, {
method: "DELETE",
token,
}),
const boards = useMemo(
() => (boardsQuery.data?.status === 200 ? boardsQuery.data.data : []),
[boardsQuery.data]
);
const agents = useMemo(() => agentsQuery.data?.data ?? [], [agentsQuery.data]);
const deleteMutation = useDeleteAgentApiV1AgentsAgentIdDelete<
ApiError,
{ previous?: listAgentsApiV1AgentsGetResponse }
>(
{
onMutate: async (agent) => {
await queryClient.cancelQueries({ queryKey: ["agents"] });
const previous = queryClient.getQueryData<Agent[]>(["agents"]);
queryClient.setQueryData<Agent[]>(["agents"], (old = []) =>
old.filter((item) => item.id !== agent.id)
);
return { previous };
mutation: {
onMutate: async ({ agentId }) => {
await queryClient.cancelQueries({ queryKey: agentsKey });
const previous =
queryClient.getQueryData<listAgentsApiV1AgentsGetResponse>(agentsKey);
if (previous) {
queryClient.setQueryData<listAgentsApiV1AgentsGetResponse>(agentsKey, {
...previous,
data: previous.data.filter((agent) => agent.id !== agentId),
});
}
return { previous };
},
onError: (_error, _agent, context) => {
if (context?.previous) {
queryClient.setQueryData(agentsKey, context.previous);
}
},
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: agentsKey });
queryClient.invalidateQueries({ queryKey: boardsKey });
},
},
onError: (_error, _agent, context) => {
if (context?.previous) {
queryClient.setQueryData(["agents"], context.previous);
}
},
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["agents"] });
},
}
},
queryClient
);
const sortedAgents = useMemo(() => [...agents], [agents]);
@@ -142,12 +163,12 @@ export default function AgentsPage() {
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget);
deleteMutation.mutate({ agentId: deleteTarget.id });
};
const columns = useMemo<ColumnDef<Agent>[]>(
const columns = useMemo<ColumnDef<AgentRead>[]>(
() => {
const resolveBoardName = (agent: Agent) =>
const resolveBoardName = (agent: AgentRead) =>
boards.find((board) => board.id === agent.board_id)?.name ?? "—";
return [
@@ -166,7 +187,9 @@ export default function AgentsPage() {
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusPill status={row.original.status} />,
cell: ({ row }) => (
<StatusPill status={row.original.status ?? "unknown"} />
),
},
{
accessorKey: "openclaw_session_id",