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",

View File

@@ -1,10 +1,21 @@
"use client";
import { useEffect, useMemo, 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 getBoardApiV1BoardsBoardIdGetResponse,
useGetBoardApiV1BoardsBoardIdGet,
useUpdateBoardApiV1BoardsBoardIdPatch,
} from "@/api/generated/boards/boards";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { BoardRead, BoardUpdate } from "@/api/generated/model";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
import { BoardGoalPanel } from "@/components/BoardGoalPanel";
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
@@ -22,28 +33,6 @@ import {
} from "@/components/ui/select";
import SearchableSelect from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase = getApiBaseUrl();
type Board = {
id: string;
name: string;
slug: string;
gateway_id?: string | null;
board_type?: string;
objective?: string | null;
success_metrics?: Record<string, unknown> | null;
target_date?: string | null;
};
type Gateway = {
id: string;
name: string;
url: string;
main_session_key: string;
workspace_root: string;
};
const slugify = (value: string) =>
value
@@ -60,158 +49,147 @@ const toDateInput = (value?: string | null) => {
};
export default function EditBoardPage() {
const { getToken, isSignedIn } = useAuth();
const { isSignedIn } = useAuth();
const router = useRouter();
const params = useParams();
const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
const [board, setBoard] = useState<Board | null>(null);
const [name, setName] = useState("");
const [gateways, setGateways] = useState<Gateway[]>([]);
const [gatewayId, setGatewayId] = useState<string>("");
const [boardType, setBoardType] = useState("goal");
const [objective, setObjective] = useState("");
const [successMetrics, setSuccessMetrics] = useState("");
const [targetDate, setTargetDate] = useState("");
const [board, setBoard] = useState<BoardRead | null>(null);
const [name, setName] = useState<string | undefined>(undefined);
const [gatewayId, setGatewayId] = useState<string | undefined>(undefined);
const [boardType, setBoardType] = useState<string | undefined>(undefined);
const [objective, setObjective] = useState<string | undefined>(undefined);
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
undefined,
);
const [targetDate, setTargetDate] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [metricsError, setMetricsError] = useState<string | null>(null);
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
const isFormReady = Boolean(name.trim() && gatewayId);
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const boardQuery = useGetBoardApiV1BoardsBoardIdGet<
getBoardApiV1BoardsBoardIdGetResponse,
ApiError
>(boardId ?? "", {
query: {
enabled: Boolean(isSignedIn && 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 gateways =
gatewaysQuery.data?.status === 200 ? gatewaysQuery.data.data : [];
const loadedBoard: BoardRead | null =
boardQuery.data?.status === 200 ? boardQuery.data.data : null;
const baseBoard = board ?? loadedBoard;
const resolvedName = name ?? baseBoard?.name ?? "";
const resolvedGatewayId = gatewayId ?? baseBoard?.gateway_id ?? "";
const resolvedBoardType = boardType ?? baseBoard?.board_type ?? "goal";
const resolvedObjective = objective ?? baseBoard?.objective ?? "";
const resolvedSuccessMetrics =
successMetrics ??
(baseBoard?.success_metrics
? JSON.stringify(baseBoard.success_metrics, null, 2)
: "");
const resolvedTargetDate =
targetDate ?? toDateInput(baseBoard?.target_date);
const displayGatewayId = resolvedGatewayId || gateways[0]?.id || "";
const isLoading =
gatewaysQuery.isLoading || boardQuery.isLoading || updateBoardMutation.isPending;
const errorMessage =
error ??
gatewaysQuery.error?.message ??
boardQuery.error?.message ??
null;
const isFormReady = Boolean(resolvedName.trim() && displayGatewayId);
const gatewayOptions = useMemo(
() => gateways.map((gateway) => ({ value: gateway.id, label: gateway.name })),
[gateways]
[gateways],
);
const loadGateways = async (): Promise<Gateway[]> => {
if (!isSignedIn) return [];
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/gateways`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load gateways.");
}
const data = (await response.json()) as Gateway[];
setGateways(data);
return data;
};
const loadBoard = async () => {
if (!isSignedIn || !boardId) return;
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load board.");
}
const data = (await response.json()) as Board;
setBoard(data);
setName(data.name ?? "");
if (data.gateway_id) {
setGatewayId(data.gateway_id);
}
setBoardType(data.board_type ?? "goal");
setObjective(data.objective ?? "");
setSuccessMetrics(
data.success_metrics ? JSON.stringify(data.success_metrics, null, 2) : ""
);
setTargetDate(toDateInput(data.target_date));
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
}
};
const handleOnboardingConfirmed = (updated: Board) => {
const handleOnboardingConfirmed = (updated: BoardRead) => {
setBoard(updated);
setBoardType(updated.board_type ?? "goal");
setObjective(updated.objective ?? "");
setSuccessMetrics(
updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) : ""
updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) : "",
);
setTargetDate(toDateInput(updated.target_date));
setIsOnboardingOpen(false);
};
useEffect(() => {
loadBoard();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [boardId, isSignedIn]);
useEffect(() => {
if (!isSignedIn) return;
loadGateways()
.then((configs) => {
if (!gatewayId && configs.length > 0) {
setGatewayId(configs[0].id);
}
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Something went wrong.");
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn || !boardId) return;
if (!name.trim()) {
const trimmedName = resolvedName.trim();
if (!trimmedName) {
setError("Board name is required.");
return;
}
if (!gatewayId) {
const resolvedGatewayId = displayGatewayId;
if (!resolvedGatewayId) {
setError("Select a gateway before saving.");
return;
}
setIsLoading(true);
setError(null);
setMetricsError(null);
try {
const token = await getToken();
let parsedMetrics: Record<string, unknown> | null = null;
if (successMetrics.trim()) {
try {
parsedMetrics = JSON.parse(successMetrics) as Record<string, unknown>;
} catch {
setMetricsError("Success metrics must be valid JSON.");
setIsLoading(false);
return;
}
}
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({
name: name.trim(),
slug: slugify(name.trim()),
gateway_id: gatewayId || null,
board_type: boardType,
objective: objective.trim() || null,
success_metrics: parsedMetrics,
target_date: targetDate ? new Date(targetDate).toISOString() : null,
}),
});
if (!response.ok) {
throw new Error("Unable to update board.");
let parsedMetrics: Record<string, unknown> | null = null;
if (resolvedSuccessMetrics.trim()) {
try {
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<string, unknown>;
} catch {
setMetricsError("Success metrics must be valid JSON.");
return;
}
const updated = (await response.json()) as Board;
router.push(`/boards/${updated.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
const payload: BoardUpdate = {
name: trimmedName,
slug: slugify(trimmedName),
gateway_id: resolvedGatewayId || null,
board_type: resolvedBoardType,
objective: resolvedObjective.trim() || null,
success_metrics: parsedMetrics,
target_date: resolvedTargetDate
? new Date(resolvedTargetDate).toISOString()
: null,
};
updateBoardMutation.mutate({ boardId, data: payload });
};
return (
@@ -249,7 +227,7 @@ export default function EditBoardPage() {
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<BoardGoalPanel
board={board}
board={baseBoard}
onStartOnboarding={() => setIsOnboardingOpen(true)}
/>
<form
@@ -262,22 +240,22 @@ export default function EditBoardPage() {
Board name <span className="text-red-500">*</span>
</label>
<Input
value={name}
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !board}
disabled={isLoading || !baseBoard}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={gatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
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"
@@ -292,7 +270,7 @@ export default function EditBoardPage() {
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select value={boardType} onValueChange={setBoardType}>
<Select value={resolvedBoardType} onValueChange={setBoardType}>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
@@ -308,7 +286,7 @@ export default function EditBoardPage() {
</label>
<Input
type="date"
value={targetDate}
value={resolvedTargetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
@@ -320,7 +298,7 @@ export default function EditBoardPage() {
Objective
</label>
<Textarea
value={objective}
value={resolvedObjective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
@@ -333,7 +311,7 @@ export default function EditBoardPage() {
Success metrics (JSON)
</label>
<Textarea
value={successMetrics}
value={resolvedSuccessMetrics}
onChange={(event) => setSuccessMetrics(event.target.value)}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
@@ -353,7 +331,9 @@ export default function EditBoardPage() {
</div>
) : null}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
@@ -364,7 +344,7 @@ export default function EditBoardPage() {
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !board || !isFormReady}>
<Button type="submit" disabled={isLoading || !baseBoard || !isFormReady}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>

View File

@@ -28,75 +28,71 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { getApiBaseUrl } from "@/lib/api-base";
import { listAgentsApiV1AgentsGet, streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents";
import {
listApprovalsApiV1BoardsBoardIdApprovalsGet,
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
} from "@/api/generated/approvals/approvals";
import { getBoardApiV1BoardsBoardIdGet } from "@/api/generated/boards/boards";
import {
createBoardMemoryApiV1BoardsBoardIdMemoryPost,
listBoardMemoryApiV1BoardsBoardIdMemoryGet,
streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet,
} from "@/api/generated/board-memory/board-memory";
import {
createTaskApiV1BoardsBoardIdTasksPost,
createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost,
deleteTaskApiV1BoardsBoardIdTasksTaskIdDelete,
listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGet,
listTasksApiV1BoardsBoardIdTasksGet,
streamTasksApiV1BoardsBoardIdTasksStreamGet,
updateTaskApiV1BoardsBoardIdTasksTaskIdPatch,
} from "@/api/generated/tasks/tasks";
import type {
AgentRead,
ApprovalRead,
BoardMemoryRead,
BoardRead,
TaskCommentRead,
TaskRead,
} from "@/api/generated/model";
import { cn } from "@/lib/utils";
type Board = {
id: string;
name: string;
slug: string;
board_type?: string;
objective?: string | null;
success_metrics?: Record<string, unknown> | null;
target_date?: string | null;
goal_confirmed?: boolean;
};
type Board = BoardRead;
type Task = {
id: string;
title: string;
description?: string | null;
status: string;
type TaskStatus = Exclude<TaskRead["status"], undefined>;
type Task = TaskRead & {
status: TaskStatus;
priority: string;
due_at?: string | null;
assigned_agent_id?: string | null;
created_at?: string | null;
updated_at?: string | null;
approvalsCount?: number;
approvalsPendingCount?: number;
};
type Agent = {
id: string;
name: string;
status: string;
board_id?: string | null;
is_board_lead?: boolean;
updated_at?: string | null;
last_seen_at?: string | null;
identity_profile?: {
emoji?: string | null;
} | null;
};
type Agent = AgentRead & { status: string };
type TaskComment = {
id: string;
message?: string | null;
agent_id?: string | null;
task_id?: string | null;
created_at: string;
};
type TaskComment = TaskCommentRead;
type Approval = {
id: string;
action_type: string;
payload?: Record<string, unknown> | null;
confidence: number;
rubric_scores?: Record<string, number> | null;
status: string;
created_at: string;
resolved_at?: string | null;
};
type Approval = ApprovalRead & { status: string };
type BoardChatMessage = {
id: string;
content: string;
tags?: string[] | null;
source?: string | null;
created_at: string;
};
type BoardChatMessage = BoardMemoryRead;
const apiBase = getApiBaseUrl();
const normalizeTask = (task: TaskRead): Task => ({
...task,
status: task.status ?? "inbox",
priority: task.priority ?? "medium",
});
const normalizeAgent = (agent: AgentRead): Agent => ({
...agent,
status: agent.status ?? "offline",
});
const normalizeApproval = (approval: ApprovalRead): Approval => ({
...approval,
status: approval.status ?? "pending",
});
const approvalTaskId = (approval: Approval) => {
const payload = approval.payload ?? {};
@@ -137,7 +133,7 @@ export default function BoardDetailPage() {
const params = useParams();
const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
const { getToken, isSignedIn } = useAuth();
const { isSignedIn } = useAuth();
const [board, setBoard] = useState<Board | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
@@ -195,7 +191,7 @@ export default function BoardDetailPage() {
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editStatus, setEditStatus] = useState("inbox");
const [editStatus, setEditStatus] = useState<TaskStatus>("inbox");
const [editPriority, setEditPriority] = useState("medium");
const [editAssigneeId, setEditAssigneeId] = useState("");
const [isSavingTask, setIsSavingTask] = useState(false);
@@ -250,41 +246,18 @@ export default function BoardDetailPage() {
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const [boardResponse, tasksResponse, agentsResponse] = await Promise.all([
fetch(`${apiBase}/api/v1/boards/${boardId}`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
}),
fetch(`${apiBase}/api/v1/boards/${boardId}/tasks`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
}),
fetch(`${apiBase}/api/v1/agents`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
}),
const [boardResult, tasksResult, agentsResult] = await Promise.all([
getBoardApiV1BoardsBoardIdGet(boardId),
listTasksApiV1BoardsBoardIdTasksGet(boardId),
listAgentsApiV1AgentsGet(),
]);
if (!boardResponse.ok) {
throw new Error("Unable to load board.");
}
if (!tasksResponse.ok) {
throw new Error("Unable to load tasks.");
}
if (!agentsResponse.ok) {
throw new Error("Unable to load agents.");
}
if (boardResult.status !== 200) throw new Error("Unable to load board.");
if (tasksResult.status !== 200) throw new Error("Unable to load tasks.");
const boardData = (await boardResponse.json()) as Board;
const taskData = (await tasksResponse.json()) as Task[];
const agentData = (await agentsResponse.json()) as Agent[];
setBoard(boardData);
setTasks(taskData);
setAgents(agentData);
setBoard(boardResult.data);
setTasks(tasksResult.data.map(normalizeTask));
setAgents(agentsResult.data.map(normalizeAgent));
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
@@ -326,20 +299,9 @@ export default function BoardDetailPage() {
setIsApprovalsLoading(true);
setApprovalsError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/approvals`,
{
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
},
);
if (!response.ok) {
throw new Error("Unable to load approvals.");
}
const data = (await response.json()) as Approval[];
setApprovals(data);
const result = await listApprovalsApiV1BoardsBoardIdApprovalsGet(boardId);
if (result.status !== 200) throw new Error("Unable to load approvals.");
setApprovals(result.data.map(normalizeApproval));
} catch (err) {
setApprovalsError(
err instanceof Error ? err.message : "Unable to load approvals.",
@@ -347,7 +309,7 @@ export default function BoardDetailPage() {
} finally {
setIsApprovalsLoading(false);
}
}, [boardId, getToken, isSignedIn]);
}, [boardId, isSignedIn]);
useEffect(() => {
loadApprovals();
@@ -357,19 +319,11 @@ export default function BoardDetailPage() {
if (!isSignedIn || !boardId) return;
setChatError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/memory?limit=200`,
{
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
},
);
if (!response.ok) {
throw new Error("Unable to load board chat.");
}
const data = (await response.json()) as BoardChatMessage[];
const result = await listBoardMemoryApiV1BoardsBoardIdMemoryGet(boardId, {
limit: 200,
});
if (result.status !== 200) throw new Error("Unable to load board chat.");
const data = result.data;
const chatOnly = data.filter((item) => item.tags?.includes("chat"));
const ordered = chatOnly.sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
@@ -382,7 +336,7 @@ export default function BoardDetailPage() {
err instanceof Error ? err.message : "Unable to load board chat.",
);
}
}, [boardId, getToken, isSignedIn]);
}, [boardId, isSignedIn]);
useEffect(() => {
loadBoardChat();
@@ -405,22 +359,21 @@ export default function BoardDetailPage() {
const connect = async () => {
try {
const token = await getToken();
if (!token || isCancelled) return;
const url = new URL(
`${apiBase}/api/v1/boards/${boardId}/memory/stream`,
);
const since = latestChatTimestamp(chatMessagesRef.current);
if (since) {
url.searchParams.set("since", since);
const streamResult =
await streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet(
boardId,
since ? { since } : undefined,
{
headers: { Accept: "text/event-stream" },
signal: abortController.signal,
},
);
if (streamResult.status !== 200) {
throw new Error("Unable to connect board chat stream.");
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
signal: abortController.signal,
});
if (!response.ok || !response.body) {
const response = streamResult.data as Response;
if (!(response instanceof Response) || !response.body) {
throw new Error("Unable to connect board chat stream.");
}
const reader = response.body.getReader();
@@ -483,7 +436,7 @@ export default function BoardDetailPage() {
isCancelled = true;
abortController.abort();
};
}, [boardId, getToken, isSignedIn]);
}, [boardId, isSignedIn]);
useEffect(() => {
if (!isSignedIn || !boardId) return;
@@ -492,22 +445,21 @@ export default function BoardDetailPage() {
const connect = async () => {
try {
const token = await getToken();
if (!token || isCancelled) return;
const url = new URL(
`${apiBase}/api/v1/boards/${boardId}/approvals/stream`,
);
const since = latestApprovalTimestamp(approvalsRef.current);
if (since) {
url.searchParams.set("since", since);
const streamResult =
await streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet(
boardId,
since ? { since } : undefined,
{
headers: { Accept: "text/event-stream" },
signal: abortController.signal,
},
);
if (streamResult.status !== 200) {
throw new Error("Unable to connect approvals stream.");
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
signal: abortController.signal,
});
if (!response.ok || !response.body) {
const response = streamResult.data as Response;
if (!(response instanceof Response) || !response.body) {
throw new Error("Unable to connect approvals stream.");
}
const reader = response.body.getReader();
@@ -535,19 +487,20 @@ export default function BoardDetailPage() {
}
if (eventType === "approval" && data) {
try {
const payload = JSON.parse(data) as { approval?: Approval };
const payload = JSON.parse(data) as { approval?: ApprovalRead };
if (payload.approval) {
const normalized = normalizeApproval(payload.approval);
setApprovals((prev) => {
const index = prev.findIndex(
(item) => item.id === payload.approval?.id,
(item) => item.id === normalized.id,
);
if (index === -1) {
return [payload.approval as Approval, ...prev];
return [normalized, ...prev];
}
const next = [...prev];
next[index] = {
...next[index],
...(payload.approval as Approval),
...normalized,
};
return next;
});
@@ -571,7 +524,7 @@ export default function BoardDetailPage() {
isCancelled = true;
abortController.abort();
};
}, [boardId, getToken, isSignedIn]);
}, [boardId, isSignedIn]);
useEffect(() => {
if (!selectedTask) {
@@ -598,20 +551,20 @@ export default function BoardDetailPage() {
const connect = async () => {
try {
const token = await getToken();
if (!token || isCancelled) return;
const url = new URL(`${apiBase}/api/v1/boards/${boardId}/tasks/stream`);
const since = latestTaskTimestamp(tasksRef.current);
if (since) {
url.searchParams.set("since", since);
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
const streamResult = await streamTasksApiV1BoardsBoardIdTasksStreamGet(
boardId,
since ? { since } : undefined,
{
headers: { Accept: "text/event-stream" },
signal: abortController.signal,
},
signal: abortController.signal,
});
if (!response.ok || !response.body) {
);
if (streamResult.status !== 200) {
throw new Error("Unable to connect task stream.");
}
const response = streamResult.data as Response;
if (!(response instanceof Response) || !response.body) {
throw new Error("Unable to connect task stream.");
}
const reader = response.body.getReader();
@@ -641,11 +594,11 @@ export default function BoardDetailPage() {
try {
const payload = JSON.parse(data) as {
type?: string;
task?: Task;
comment?: TaskComment;
task?: TaskRead;
comment?: TaskCommentRead;
};
if (payload.comment?.task_id && payload.type === "task.comment") {
pushLiveFeed(payload.comment as TaskComment);
pushLiveFeed(payload.comment);
setComments((prev) => {
if (selectedTask?.id !== payload.comment?.task_id) {
return prev;
@@ -657,13 +610,14 @@ export default function BoardDetailPage() {
return [...prev, payload.comment as TaskComment];
});
} else if (payload.task) {
const normalizedTask = normalizeTask(payload.task);
setTasks((prev) => {
const index = prev.findIndex((item) => item.id === payload.task?.id);
const index = prev.findIndex((item) => item.id === normalizedTask.id);
if (index === -1) {
return [payload.task as Task, ...prev];
return [normalizedTask, ...prev];
}
const next = [...prev];
next[index] = { ...next[index], ...(payload.task as Task) };
next[index] = { ...next[index], ...normalizedTask };
return next;
});
}
@@ -686,7 +640,7 @@ export default function BoardDetailPage() {
isCancelled = true;
abortController.abort();
};
}, [board, boardId, getToken, isSignedIn, selectedTask?.id, pushLiveFeed]);
}, [board, boardId, isSignedIn, selectedTask?.id, pushLiveFeed]);
useEffect(() => {
if (!isSignedIn || !boardId) return;
@@ -695,21 +649,22 @@ export default function BoardDetailPage() {
const connect = async () => {
try {
const token = await getToken();
if (!token || isCancelled) return;
const url = new URL(`${apiBase}/api/v1/agents/stream`);
url.searchParams.set("board_id", boardId);
const since = latestAgentTimestamp(agentsRef.current);
if (since) {
url.searchParams.set("since", since);
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
const streamResult = await streamAgentsApiV1AgentsStreamGet(
{
board_id: boardId,
since: since ?? null,
},
signal: abortController.signal,
});
if (!response.ok || !response.body) {
{
headers: { Accept: "text/event-stream" },
signal: abortController.signal,
},
);
if (streamResult.status !== 200) {
throw new Error("Unable to connect agent stream.");
}
const response = streamResult.data as Response;
if (!(response instanceof Response) || !response.body) {
throw new Error("Unable to connect agent stream.");
}
const reader = response.body.getReader();
@@ -737,19 +692,18 @@ export default function BoardDetailPage() {
}
if (eventType === "agent" && data) {
try {
const payload = JSON.parse(data) as { agent?: Agent };
const payload = JSON.parse(data) as { agent?: AgentRead };
if (payload.agent) {
const normalized = normalizeAgent(payload.agent);
setAgents((prev) => {
const index = prev.findIndex(
(item) => item.id === payload.agent?.id,
);
const index = prev.findIndex((item) => item.id === normalized.id);
if (index === -1) {
return [payload.agent as Agent, ...prev];
return [normalized, ...prev];
}
const next = [...prev];
next[index] = {
...next[index],
...(payload.agent as Agent),
...normalized,
};
return next;
});
@@ -773,7 +727,7 @@ export default function BoardDetailPage() {
isCancelled = true;
abortController.abort();
};
}, [boardId, getToken, isSignedIn]);
}, [boardId, isSignedIn]);
const resetForm = () => {
setTitle("");
@@ -792,26 +746,15 @@ export default function BoardDetailPage() {
setIsCreating(true);
setCreateError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({
title: trimmed,
description: description.trim() || null,
status: "inbox",
priority,
}),
const result = await createTaskApiV1BoardsBoardIdTasksPost(boardId, {
title: trimmed,
description: description.trim() || null,
status: "inbox",
priority,
});
if (result.status !== 200) throw new Error("Unable to create task.");
if (!response.ok) {
throw new Error("Unable to create task.");
}
const created = (await response.json()) as Task;
const created = normalizeTask(result.data);
setTasks((prev) => [created, ...prev]);
setIsDialogOpen(false);
resetForm();
@@ -829,25 +772,14 @@ export default function BoardDetailPage() {
setIsChatSending(true);
setChatError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/memory`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({
content: trimmed,
tags: ["chat"],
}),
},
);
if (!response.ok) {
const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost(boardId, {
content: trimmed,
tags: ["chat"],
});
if (result.status !== 200) {
throw new Error("Unable to send message.");
}
const created = (await response.json()) as BoardChatMessage;
const created = result.data;
if (created.tags?.includes("chat")) {
setChatMessages((prev) => {
const exists = prev.some((item) => item.id === created.id);
@@ -1015,18 +947,13 @@ export default function BoardDetailPage() {
setIsCommentsLoading(true);
setCommentsError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/tasks/${taskId}/comments`,
{
headers: { Authorization: token ? `Bearer ${token}` : "" },
},
);
if (!response.ok) {
throw new Error("Unable to load comments.");
}
const data = (await response.json()) as TaskComment[];
setComments(data);
const result =
await listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGet(
boardId,
taskId,
);
if (result.status !== 200) throw new Error("Unable to load comments.");
setComments(result.data);
} catch (err) {
setCommentsError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
@@ -1034,10 +961,12 @@ export default function BoardDetailPage() {
}
};
const openComments = (task: Task) => {
const openComments = (task: { id: string }) => {
setIsChatOpen(false);
setIsLiveFeedOpen(false);
setSelectedTask(task);
const fullTask = tasksRef.current.find((item) => item.id === task.id);
if (!fullTask) return;
setSelectedTask(fullTask);
setIsDetailOpen(true);
void loadComments(task.id);
};
@@ -1089,22 +1018,14 @@ export default function BoardDetailPage() {
setIsPostingComment(true);
setPostCommentError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/tasks/${selectedTask.id}/comments`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ message: trimmed }),
},
);
if (!response.ok) {
throw new Error("Unable to send message.");
}
const created = (await response.json()) as TaskComment;
const result =
await createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost(
boardId,
selectedTask.id,
{ message: trimmed },
);
if (result.status !== 200) throw new Error("Unable to send message.");
const created = result.data;
setComments((prev) => [created, ...prev]);
setNewComment("");
} catch (err) {
@@ -1126,28 +1047,19 @@ export default function BoardDetailPage() {
setIsSavingTask(true);
setSaveTaskError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/tasks/${selectedTask.id}`,
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
boardId,
selectedTask.id,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({
title: trimmedTitle,
description: editDescription.trim() || null,
status: editStatus,
priority: editPriority,
assigned_agent_id: editAssigneeId || null,
}),
title: trimmedTitle,
description: editDescription.trim() || null,
status: editStatus,
priority: editPriority,
assigned_agent_id: editAssigneeId || null,
},
);
if (!response.ok) {
throw new Error("Unable to update task.");
}
const updated = (await response.json()) as Task;
if (result.status !== 200) throw new Error("Unable to update task.");
const updated = normalizeTask(result.data);
setTasks((prev) =>
prev.map((task) => (task.id === updated.id ? updated : task)),
);
@@ -1177,19 +1089,11 @@ export default function BoardDetailPage() {
setIsDeletingTask(true);
setDeleteTaskError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/tasks/${selectedTask.id}`,
{
method: "DELETE",
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
},
const result = await deleteTaskApiV1BoardsBoardIdTasksTaskIdDelete(
boardId,
selectedTask.id,
);
if (!response.ok) {
throw new Error("Unable to delete task.");
}
if (result.status !== 200) throw new Error("Unable to delete task.");
setTasks((prev) => prev.filter((task) => task.id !== selectedTask.id));
setIsDeleteDialogOpen(false);
closeComments();
@@ -1202,7 +1106,7 @@ export default function BoardDetailPage() {
}
};
const handleTaskMove = async (taskId: string, status: string) => {
const handleTaskMove = async (taskId: string, status: TaskStatus) => {
if (!isSignedIn || !boardId) return;
const currentTask = tasksRef.current.find((task) => task.id === taskId);
if (!currentTask || currentTask.status === status) return;
@@ -1220,22 +1124,13 @@ export default function BoardDetailPage() {
),
);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/tasks/${taskId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ status }),
},
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
boardId,
taskId,
{ status },
);
if (!response.ok) {
throw new Error("Unable to move task.");
}
const updated = (await response.json()) as Task;
if (result.status !== 200) throw new Error("Unable to move task.");
const updated = normalizeTask(result.data);
setTasks((prev) =>
prev.map((task) => (task.id === updated.id ? updated : task)),
);
@@ -1265,7 +1160,12 @@ export default function BoardDetailPage() {
const agentAvatarLabel = (agent: Agent) => {
if (agent.is_board_lead) return "⚙️";
const emoji = resolveEmoji(agent.identity_profile?.emoji ?? null);
let emojiValue: string | null = null;
if (agent.identity_profile && typeof agent.identity_profile === "object") {
const rawEmoji = (agent.identity_profile as Record<string, unknown>).emoji;
emojiValue = typeof rawEmoji === "string" ? rawEmoji : null;
}
const emoji = resolveEmoji(emojiValue);
return emoji ?? agentInitials(agent);
};
@@ -1351,8 +1251,8 @@ export default function BoardDetailPage() {
payload: Approval["payload"],
key: string,
) => {
if (!payload) return null;
const value = payload[key as keyof typeof payload];
if (!payload || typeof payload !== "object") return null;
const value = (payload as Record<string, unknown>)[key];
if (typeof value === "string" || typeof value === "number") {
return String(value);
}
@@ -1393,22 +1293,16 @@ export default function BoardDetailPage() {
setApprovalsUpdatingId(approvalId);
setApprovalsError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/approvals/${approvalId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ status }),
},
);
if (!response.ok) {
const result =
await updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch(
boardId,
approvalId,
{ status },
);
if (result.status !== 200) {
throw new Error("Unable to update approval.");
}
const updated = (await response.json()) as Approval;
const updated = normalizeApproval(result.data);
setApprovals((prev) =>
prev.map((item) => (item.id === approvalId ? updated : item)),
);
@@ -1420,7 +1314,7 @@ export default function BoardDetailPage() {
setApprovalsUpdatingId(null);
}
},
[boardId, getToken, isSignedIn],
[boardId, isSignedIn],
);
return (
@@ -2219,7 +2113,7 @@ export default function BoardDetailPage() {
</label>
<Select
value={editStatus}
onValueChange={setEditStatus}
onValueChange={(value) => setEditStatus(value as TaskStatus)}
disabled={!selectedTask || isSavingTask}
>
<SelectTrigger>

View File

@@ -1,34 +1,22 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { ApiError } from "@/api/mutator";
import { useCreateBoardApiV1BoardsPost } from "@/api/generated/boards/boards";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import SearchableSelect from "@/components/ui/searchable-select";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase = getApiBaseUrl();
type Board = {
id: string;
name: string;
slug: string;
gateway_id?: string | null;
};
type Gateway = {
id: string;
name: string;
url: string;
main_session_key: string;
workspace_root: string;
};
const slugify = (value: string) =>
value
@@ -39,89 +27,73 @@ const slugify = (value: string) =>
export default function NewBoardPage() {
const router = useRouter();
const { getToken, isSignedIn } = useAuth();
const { isSignedIn } = useAuth();
const [name, setName] = useState("");
const [gateways, setGateways] = useState<Gateway[]>([]);
const [gatewayId, setGatewayId] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isFormReady = Boolean(name.trim() && gatewayId);
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const createBoardMutation = useCreateBoardApiV1BoardsPost<ApiError>({
mutation: {
onSuccess: (result) => {
if (result.status === 200) {
router.push(`/boards/${result.data.id}`);
}
},
onError: (err) => {
setError(err.message || "Something went wrong.");
},
},
});
const gateways =
gatewaysQuery.data?.status === 200 ? gatewaysQuery.data.data : [];
const displayGatewayId = gatewayId || gateways[0]?.id || "";
const isLoading = gatewaysQuery.isLoading || createBoardMutation.isPending;
const errorMessage = error ?? gatewaysQuery.error?.message ?? null;
const isFormReady = Boolean(name.trim() && displayGatewayId);
const gatewayOptions = useMemo(
() => gateways.map((gateway) => ({ value: gateway.id, label: gateway.name })),
[gateways]
);
const loadGateways = async () => {
if (!isSignedIn) return;
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/gateways`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load gateways.");
}
const data = (await response.json()) as Gateway[];
setGateways(data);
if (!gatewayId && data.length > 0) {
setGatewayId(data[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
}
};
useEffect(() => {
loadGateways();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn) return;
const trimmedName = name.trim();
const resolvedGatewayId = displayGatewayId;
if (!trimmedName) {
setError("Board name is required.");
return;
}
if (!gatewayId) {
if (!resolvedGatewayId) {
setError("Select a gateway before creating a board.");
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const payload: Partial<Board> = {
createBoardMutation.mutate({
data: {
name: trimmedName,
slug: slugify(trimmedName),
gateway_id: gatewayId || null,
};
const response = await fetch(`${apiBase}/api/v1/boards`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("Unable to create board.");
}
const created = (await response.json()) as Board;
router.push(`/boards/${created.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
gateway_id: resolvedGatewayId,
},
});
};
return (
@@ -178,7 +150,7 @@ export default function NewBoardPage() {
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={gatewayId}
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
@@ -208,7 +180,9 @@ export default function NewBoardPage() {
</div>
) : null}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button

View File

@@ -3,7 +3,7 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import {
type ColumnDef,
flexRender,
@@ -12,10 +12,17 @@ import {
} from "@tanstack/react-table";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import {
type listBoardsApiV1BoardsGetResponse,
getListBoardsApiV1BoardsGetQueryKey,
useDeleteBoardApiV1BoardsBoardIdDelete,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import type { BoardRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button, buttonVariants } from "@/components/ui/button";
import { apiRequest, useAuthedMutation, useAuthedQuery } from "@/lib/api-query";
import {
Dialog,
DialogContent,
@@ -25,13 +32,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
type Board = {
id: string;
name: string;
slug: string;
updated_at: string;
};
const formatTimestamp = (value?: string | null) => {
if (!value) return "—";
const date = new Date(`${value}${value.endsWith("Z") ? "" : "Z"}`);
@@ -45,56 +45,72 @@ const formatTimestamp = (value?: string | null) => {
};
export default function BoardsPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = useState<Board | null>(null);
const [deleteTarget, setDeleteTarget] = useState<BoardRead | null>(null);
const boardsQuery = useAuthedQuery<Board[]>(["boards"], "/api/v1/boards", {
refetchInterval: 30_000,
refetchOnMount: "always",
const boardsKey = getListBoardsApiV1BoardsGetQueryKey();
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
refetchOnMount: "always",
},
});
const boards = useMemo(() => boardsQuery.data ?? [], [boardsQuery.data]);
const boards = useMemo(
() => (boardsQuery.data?.status === 200 ? boardsQuery.data.data : []),
[boardsQuery.data]
);
const sortedBoards = useMemo(
() => [...boards].sort((a, b) => a.name.localeCompare(b.name)),
[boards]
);
const deleteMutation = useAuthedMutation<void, Board, { previous?: Board[] }>(
async (board, token) =>
apiRequest(`/api/v1/boards/${board.id}`, {
method: "DELETE",
token,
}),
const deleteMutation = useDeleteBoardApiV1BoardsBoardIdDelete<
ApiError,
{ previous?: listBoardsApiV1BoardsGetResponse }
>(
{
onMutate: async (board) => {
await queryClient.cancelQueries({ queryKey: ["boards"] });
const previous = queryClient.getQueryData<Board[]>(["boards"]);
queryClient.setQueryData<Board[]>(["boards"], (old = []) =>
old.filter((item) => item.id !== board.id)
);
return { previous };
mutation: {
onMutate: async ({ boardId }) => {
await queryClient.cancelQueries({ queryKey: boardsKey });
const previous =
queryClient.getQueryData<listBoardsApiV1BoardsGetResponse>(boardsKey);
if (previous && previous.status === 200) {
queryClient.setQueryData<listBoardsApiV1BoardsGetResponse>(boardsKey, {
...previous,
data: previous.data.filter((board) => board.id !== boardId),
});
}
return { previous };
},
onError: (_error, _board, context) => {
if (context?.previous) {
queryClient.setQueryData(boardsKey, context.previous);
}
},
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: boardsKey });
},
},
onError: (_error, _board, context) => {
if (context?.previous) {
queryClient.setQueryData(["boards"], context.previous);
}
},
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["boards"] });
},
}
},
queryClient
);
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget);
deleteMutation.mutate({ boardId: deleteTarget.id });
};
const columns = useMemo<ColumnDef<Board>[]>(
const columns = useMemo<ColumnDef<BoardRead>[]>(
() => [
{
accessorKey: "name",

View File

@@ -2,7 +2,7 @@
import { useMemo } from "react";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import {
Area,
AreaChart,
@@ -22,7 +22,11 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import MetricSparkline from "@/components/charts/metric-sparkline";
import { useAuthedQuery } from "@/lib/api-query";
import { ApiError } from "@/api/mutator";
import {
type dashboardMetricsApiV1MetricsDashboardGetResponse,
useDashboardMetricsApiV1MetricsDashboardGet,
} from "@/api/generated/metrics/metrics";
type RangeKey = "24h" | "7d";
type BucketKey = "hour" | "day";
@@ -249,16 +253,23 @@ function ChartCard({
}
export default function DashboardPage() {
const metricsQuery = useAuthedQuery<DashboardMetrics>(
["metrics", "dashboard", "24h"],
"/api/v1/metrics/dashboard?range=24h",
const { isSignedIn } = useAuth();
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
dashboardMetricsApiV1MetricsDashboardGetResponse,
ApiError
>(
{ range: "24h" },
{
refetchInterval: 15_000,
refetchOnMount: "always",
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 15_000,
refetchOnMount: "always",
},
},
);
const metrics = metricsQuery.data ?? null;
const metrics =
metricsQuery.data?.status === 200 ? metricsQuery.data.data : null;
const throughputSeries = useMemo(
() => (metrics ? buildSeries(metrics.throughput.primary) : []),

View File

@@ -1,18 +1,23 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
import { ApiError } from "@/api/mutator";
import {
gatewaysStatusApiV1GatewaysStatusGet,
type getGatewayApiV1GatewaysGatewayIdGetResponse,
useGetGatewayApiV1GatewaysGatewayIdGet,
useUpdateGatewayApiV1GatewaysGatewayIdPatch,
} from "@/api/generated/gateways/gateways";
import type { GatewayUpdate } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase = getApiBaseUrl();
const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
@@ -34,18 +39,8 @@ const validateGatewayUrl = (value: string) => {
}
};
type Gateway = {
id: string;
name: string;
url: string;
token?: string | null;
main_session_key: string;
workspace_root: string;
skyll_enabled?: boolean;
};
export default function EditGatewayPage() {
const { getToken, isSignedIn } = useAuth();
const { isSignedIn } = useAuth();
const router = useRouter();
const params = useParams();
const gatewayIdParam = params?.gatewayId;
@@ -53,15 +48,20 @@ export default function EditGatewayPage() {
? gatewayIdParam[0]
: gatewayIdParam;
const [gateway, setGateway] = useState<Gateway | null>(null);
const [name, setName] = useState("");
const [gatewayUrl, setGatewayUrl] = useState("");
const [gatewayToken, setGatewayToken] = useState("");
const [mainSessionKey, setMainSessionKey] = useState(
DEFAULT_MAIN_SESSION_KEY
const [name, setName] = useState<string | undefined>(undefined);
const [gatewayUrl, setGatewayUrl] = useState<string | undefined>(undefined);
const [gatewayToken, setGatewayToken] = useState<string | undefined>(
undefined,
);
const [mainSessionKey, setMainSessionKey] = useState<string | undefined>(
undefined,
);
const [workspaceRoot, setWorkspaceRoot] = useState<string | undefined>(
undefined,
);
const [skyllEnabled, setSkyllEnabled] = useState<boolean | undefined>(
undefined,
);
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
const [skyllEnabled, setSkyllEnabled] = useState(false);
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
@@ -71,48 +71,58 @@ export default function EditGatewayPage() {
null
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet<
getGatewayApiV1GatewaysGatewayIdGetResponse,
ApiError
>(gatewayId ?? "", {
query: {
enabled: Boolean(isSignedIn && gatewayId),
refetchOnMount: "always",
retry: false,
},
});
const updateMutation = useUpdateGatewayApiV1GatewaysGatewayIdPatch<ApiError>({
mutation: {
onSuccess: (result) => {
if (result.status === 200) {
router.push(`/gateways/${result.data.id}`);
}
},
onError: (err) => {
setError(err.message || "Something went wrong.");
},
},
});
const loadedGateway =
gatewayQuery.data?.status === 200 ? gatewayQuery.data.data : null;
const resolvedName = name ?? loadedGateway?.name ?? "";
const resolvedGatewayUrl = gatewayUrl ?? loadedGateway?.url ?? "";
const resolvedGatewayToken = gatewayToken ?? loadedGateway?.token ?? "";
const resolvedMainSessionKey =
mainSessionKey ??
loadedGateway?.main_session_key ??
DEFAULT_MAIN_SESSION_KEY;
const resolvedWorkspaceRoot =
workspaceRoot ?? loadedGateway?.workspace_root ?? DEFAULT_WORKSPACE_ROOT;
const resolvedSkyllEnabled =
skyllEnabled ?? Boolean(loadedGateway?.skyll_enabled);
const isLoading = gatewayQuery.isLoading || updateMutation.isPending;
const errorMessage = error ?? gatewayQuery.error?.message ?? null;
const canSubmit =
Boolean(name.trim()) &&
Boolean(gatewayUrl.trim()) &&
Boolean(mainSessionKey.trim()) &&
Boolean(workspaceRoot.trim()) &&
Boolean(resolvedName.trim()) &&
Boolean(resolvedGatewayUrl.trim()) &&
Boolean(resolvedMainSessionKey.trim()) &&
Boolean(resolvedWorkspaceRoot.trim()) &&
gatewayCheckStatus === "success";
useEffect(() => {
if (!isSignedIn || !gatewayId) return;
const loadGateway = async () => {
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/gateways/${gatewayId}`,
{
headers: { Authorization: token ? `Bearer ${token}` : "" },
}
);
if (!response.ok) {
throw new Error("Unable to load gateway.");
}
const data = (await response.json()) as Gateway;
setGateway(data);
setName(data.name ?? "");
setGatewayUrl(data.url ?? "");
setGatewayToken(data.token ?? "");
setMainSessionKey(data.main_session_key ?? DEFAULT_MAIN_SESSION_KEY);
setWorkspaceRoot(data.workspace_root ?? DEFAULT_WORKSPACE_ROOT);
setSkyllEnabled(Boolean(data.skyll_enabled));
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
}
};
loadGateway();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gatewayId, isSignedIn]);
const runGatewayCheck = async () => {
const validationError = validateGatewayUrl(gatewayUrl);
const validationError = validateGatewayUrl(resolvedGatewayUrl);
setGatewayUrlError(validationError);
if (validationError) {
setGatewayCheckStatus("error");
@@ -123,26 +133,25 @@ export default function EditGatewayPage() {
setGatewayCheckStatus("checking");
setGatewayCheckMessage(null);
try {
const token = await getToken();
const params = new URLSearchParams({
gateway_url: gatewayUrl.trim(),
});
if (gatewayToken.trim()) {
params.set("gateway_token", gatewayToken.trim());
const params: Record<string, string> = {
gateway_url: resolvedGatewayUrl.trim(),
};
if (resolvedGatewayToken.trim()) {
params.gateway_token = resolvedGatewayToken.trim();
}
if (mainSessionKey.trim()) {
params.set("gateway_main_session_key", mainSessionKey.trim());
if (resolvedMainSessionKey.trim()) {
params.gateway_main_session_key = resolvedMainSessionKey.trim();
}
const response = await fetch(
`${apiBase}/api/v1/gateways/status?${params.toString()}`,
{
headers: { Authorization: token ? `Bearer ${token}` : "" },
}
);
const data = await response.json();
if (!response.ok || !data?.connected) {
const response = await gatewaysStatusApiV1GatewaysStatusGet(params);
if (response.status !== 200) {
setGatewayCheckStatus("error");
setGatewayCheckMessage(data?.error ?? "Unable to reach gateway.");
setGatewayCheckMessage("Unable to reach gateway.");
return;
}
const data = response.data;
if (!data.connected) {
setGatewayCheckStatus("error");
setGatewayCheckMessage(data.error ?? "Unable to reach gateway.");
return;
}
setGatewayCheckStatus("success");
@@ -155,60 +164,42 @@ export default function EditGatewayPage() {
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn || !gatewayId) return;
if (!name.trim()) {
if (!resolvedName.trim()) {
setError("Gateway name is required.");
return;
}
const gatewayValidation = validateGatewayUrl(gatewayUrl);
const gatewayValidation = validateGatewayUrl(resolvedGatewayUrl);
setGatewayUrlError(gatewayValidation);
if (gatewayValidation) {
setGatewayCheckStatus("error");
setGatewayCheckMessage(gatewayValidation);
return;
}
if (!mainSessionKey.trim()) {
if (!resolvedMainSessionKey.trim()) {
setError("Main session key is required.");
return;
}
if (!workspaceRoot.trim()) {
if (!resolvedWorkspaceRoot.trim()) {
setError("Workspace root is required.");
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/gateways/${gatewayId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({
name: name.trim(),
url: gatewayUrl.trim(),
token: gatewayToken.trim() || null,
main_session_key: mainSessionKey.trim(),
workspace_root: workspaceRoot.trim(),
skyll_enabled: skyllEnabled,
}),
});
if (!response.ok) {
throw new Error("Unable to update gateway.");
}
const updated = (await response.json()) as Gateway;
setGateway(updated);
router.push(`/gateways/${updated.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
const payload: GatewayUpdate = {
name: resolvedName.trim(),
url: resolvedGatewayUrl.trim(),
token: resolvedGatewayToken.trim() || null,
main_session_key: resolvedMainSessionKey.trim(),
workspace_root: resolvedWorkspaceRoot.trim(),
skyll_enabled: resolvedSkyllEnabled,
};
updateMutation.mutate({ gatewayId, data: payload });
};
return (
@@ -232,7 +223,9 @@ export default function EditGatewayPage() {
<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">
{gateway ? `Edit gateway — ${gateway.name}` : "Edit gateway"}
{resolvedName.trim()
? `Edit gateway — ${resolvedName.trim()}`
: "Edit gateway"}
</h1>
<p className="mt-1 text-sm text-slate-500">
Update connection settings for this OpenClaw gateway.
@@ -251,7 +244,7 @@ export default function EditGatewayPage() {
Gateway name <span className="text-red-500">*</span>
</label>
<Input
value={name}
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Primary gateway"
disabled={isLoading}
@@ -273,15 +266,15 @@ export default function EditGatewayPage() {
<button
type="button"
role="switch"
aria-checked={skyllEnabled}
onClick={() => setSkyllEnabled((prev) => !prev)}
aria-checked={resolvedSkyllEnabled}
onClick={() => setSkyllEnabled(!resolvedSkyllEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition ${
skyllEnabled ? "bg-blue-600" : "bg-slate-200"
resolvedSkyllEnabled ? "bg-blue-600" : "bg-slate-200"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition ${
skyllEnabled ? "translate-x-5" : "translate-x-1"
resolvedSkyllEnabled ? "translate-x-5" : "translate-x-1"
}`}
/>
</button>
@@ -296,7 +289,7 @@ export default function EditGatewayPage() {
</label>
<div className="relative">
<Input
value={gatewayUrl}
value={resolvedGatewayUrl}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
@@ -344,7 +337,7 @@ export default function EditGatewayPage() {
Gateway token
</label>
<Input
value={gatewayToken}
value={resolvedGatewayToken}
onChange={(event) => {
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
@@ -363,7 +356,7 @@ export default function EditGatewayPage() {
Main session key <span className="text-red-500">*</span>
</label>
<Input
value={mainSessionKey}
value={resolvedMainSessionKey}
onChange={(event) => {
setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle");
@@ -378,7 +371,7 @@ export default function EditGatewayPage() {
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={workspaceRoot}
value={resolvedWorkspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
disabled={isLoading}
@@ -387,7 +380,9 @@ export default function EditGatewayPage() {
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button

View File

@@ -3,41 +3,27 @@
import { useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { ApiError } from "@/api/mutator";
import {
type gatewaysStatusApiV1GatewaysStatusGetResponse,
type getGatewayApiV1GatewaysGatewayIdGetResponse,
useGatewaysStatusApiV1GatewaysStatusGet,
useGetGatewayApiV1GatewaysGatewayIdGet,
} from "@/api/generated/gateways/gateways";
import {
type listAgentsApiV1AgentsGetResponse,
useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents";
import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import type { AgentRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { useAuthedQuery } from "@/lib/api-query";
type Gateway = {
id: string;
name: string;
url: string;
token?: string | null;
main_session_key: string;
workspace_root: string;
skyll_enabled?: boolean;
created_at: string;
updated_at: string;
};
type Agent = {
id: string;
name: string;
status: string;
board_id?: string | null;
last_seen_at?: string | null;
updated_at: string;
is_board_lead?: boolean;
};
type GatewayStatus = {
connected: boolean;
gateway_url: string;
sessions_count?: number;
error?: string;
};
const formatTimestamp = (value?: string | null) => {
if (!value) return "—";
@@ -60,46 +46,83 @@ const maskToken = (value?: string | null) => {
export default function GatewayDetailPage() {
const router = useRouter();
const params = useParams();
const { isSignedIn } = useAuth();
const gatewayIdParam = params?.gatewayId;
const gatewayId = Array.isArray(gatewayIdParam)
? gatewayIdParam[0]
: gatewayIdParam;
const gatewayQuery = useAuthedQuery<Gateway>(
["gateway", gatewayId ?? "unknown"],
gatewayId ? `/api/v1/gateways/${gatewayId}` : null,
{ refetchInterval: 30_000 }
);
const gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet<
getGatewayApiV1GatewaysGatewayIdGetResponse,
ApiError
>(gatewayId ?? "", {
query: {
enabled: Boolean(isSignedIn && gatewayId),
refetchInterval: 30_000,
},
});
const gateway = gatewayQuery.data ?? null;
const gateway =
gatewayQuery.data?.status === 200 ? gatewayQuery.data.data : null;
const agentsQuery = useAuthedQuery<Agent[]>(
["gateway-agents", gatewayId ?? "unknown"],
gatewayId ? `/api/v1/agents?gateway_id=${gatewayId}` : null,
{ refetchInterval: 15_000 }
);
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
},
});
const statusPath = gateway
? (() => {
const params = new URLSearchParams({ gateway_url: gateway.url });
if (gateway.token) {
params.set("gateway_token", gateway.token);
}
if (gateway.main_session_key) {
params.set("gateway_main_session_key", gateway.main_session_key);
}
return `/api/v1/gateways/status?${params.toString()}`;
})()
: null;
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 15_000,
},
});
const statusQuery = useAuthedQuery<GatewayStatus>(
["gateway-status", gatewayId ?? "unknown"],
statusPath,
{ refetchInterval: 15_000, enabled: Boolean(statusPath) }
);
const statusParams = gateway
? {
gateway_url: gateway.url,
gateway_token: gateway.token ?? undefined,
gateway_main_session_key: gateway.main_session_key ?? undefined,
}
: undefined;
const agents = agentsQuery.data ?? [];
const isConnected = statusQuery.data?.connected ?? false;
const statusQuery = useGatewaysStatusApiV1GatewaysStatusGet<
gatewaysStatusApiV1GatewaysStatusGetResponse,
ApiError
>(statusParams, {
query: {
enabled: Boolean(isSignedIn && statusParams),
refetchInterval: 15_000,
},
});
const agents = useMemo(() => {
const allAgents = agentsQuery.data?.data ?? [];
const boards = boardsQuery.data?.status === 200 ? boardsQuery.data.data : [];
if (!gatewayId) {
return allAgents;
}
const boardIds = new Set(
boards.filter((board) => board.gateway_id === gatewayId).map((board) => board.id),
);
if (boardIds.size === 0) {
return [];
}
return allAgents.filter(
(agent): agent is AgentRead => Boolean(agent.board_id && boardIds.has(agent.board_id)),
);
}, [agentsQuery.data, boardsQuery.data, gatewayId]);
const status =
statusQuery.data?.status === 200 ? statusQuery.data.data : null;
const isConnected = status?.connected ?? false;
const title = useMemo(
() => (gateway?.name ? gateway.name : "Gateway"),

View File

@@ -1,18 +1,20 @@
"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 { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
import { ApiError } from "@/api/mutator";
import {
gatewaysStatusApiV1GatewaysStatusGet,
useCreateGatewayApiV1GatewaysPost,
} from "@/api/generated/gateways/gateways";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase = getApiBaseUrl();
const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
@@ -35,7 +37,7 @@ const validateGatewayUrl = (value: string) => {
};
export default function NewGatewayPage() {
const { getToken, isSignedIn } = useAuth();
const { isSignedIn } = useAuth();
const router = useRouter();
const [name, setName] = useState("");
@@ -55,9 +57,23 @@ export default function NewGatewayPage() {
null
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createMutation = useCreateGatewayApiV1GatewaysPost<ApiError>({
mutation: {
onSuccess: (result) => {
if (result.status === 200) {
router.push(`/gateways/${result.data.id}`);
}
},
onError: (err) => {
setError(err.message || "Something went wrong.");
},
},
});
const isLoading = createMutation.isPending;
const canSubmit =
Boolean(name.trim()) &&
Boolean(gatewayUrl.trim()) &&
@@ -65,11 +81,6 @@ export default function NewGatewayPage() {
Boolean(workspaceRoot.trim()) &&
gatewayCheckStatus === "success";
useEffect(() => {
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}, [gatewayToken]);
const runGatewayCheck = async () => {
const validationError = validateGatewayUrl(gatewayUrl);
setGatewayUrlError(validationError);
@@ -82,26 +93,26 @@ export default function NewGatewayPage() {
setGatewayCheckStatus("checking");
setGatewayCheckMessage(null);
try {
const token = await getToken();
const params = new URLSearchParams({
const params: Record<string, string> = {
gateway_url: gatewayUrl.trim(),
});
};
if (gatewayToken.trim()) {
params.set("gateway_token", gatewayToken.trim());
params.gateway_token = gatewayToken.trim();
}
if (mainSessionKey.trim()) {
params.set("gateway_main_session_key", mainSessionKey.trim());
params.gateway_main_session_key = mainSessionKey.trim();
}
const response = await fetch(
`${apiBase}/api/v1/gateways/status?${params.toString()}`,
{
headers: { Authorization: token ? `Bearer ${token}` : "" },
}
);
const data = await response.json();
if (!response.ok || !data?.connected) {
const response = await gatewaysStatusApiV1GatewaysStatusGet(params);
if (response.status !== 200) {
setGatewayCheckStatus("error");
setGatewayCheckMessage(data?.error ?? "Unable to reach gateway.");
setGatewayCheckMessage("Unable to reach gateway.");
return;
}
const data = response.data;
if (!data.connected) {
setGatewayCheckStatus("error");
setGatewayCheckMessage(data.error ?? "Unable to reach gateway.");
return;
}
setGatewayCheckStatus("success");
@@ -114,7 +125,7 @@ export default function NewGatewayPage() {
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn) return;
@@ -138,35 +149,17 @@ export default function NewGatewayPage() {
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/gateways`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({
name: name.trim(),
url: gatewayUrl.trim(),
token: gatewayToken.trim() || null,
main_session_key: mainSessionKey.trim(),
workspace_root: workspaceRoot.trim(),
skyll_enabled: skyllEnabled,
}),
});
if (!response.ok) {
throw new Error("Unable to create gateway.");
}
const created = (await response.json()) as { id: string };
router.push(`/gateways/${created.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
createMutation.mutate({
data: {
name: name.trim(),
url: gatewayUrl.trim(),
token: gatewayToken.trim() || null,
main_session_key: mainSessionKey.trim(),
workspace_root: workspaceRoot.trim(),
skyll_enabled: skyllEnabled,
},
});
};
return (

View File

@@ -3,7 +3,7 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import {
type ColumnDef,
type SortingState,
@@ -25,19 +25,15 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { apiRequest, useAuthedMutation, useAuthedQuery } from "@/lib/api-query";
type Gateway = {
id: string;
name: string;
url: string;
token?: string | null;
main_session_key: string;
workspace_root: string;
skyll_enabled?: boolean;
created_at: string;
updated_at: string;
};
import { ApiError } from "@/api/mutator";
import {
type listGatewaysApiV1GatewaysGetResponse,
getListGatewaysApiV1GatewaysGetQueryKey,
useDeleteGatewayApiV1GatewaysGatewayIdDelete,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { GatewayRead } from "@/api/generated/model";
const truncate = (value?: string | null, max = 24) => {
if (!value) return "—";
@@ -58,62 +54,68 @@ const formatTimestamp = (value?: string | null) => {
};
export default function GatewaysPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const [deleteTarget, setDeleteTarget] = useState<Gateway | null>(null);
const gatewaysQuery = useAuthedQuery<Gateway[]>(
["gateways"],
"/api/v1/gateways",
{
const [deleteTarget, setDeleteTarget] = useState<GatewayRead | null>(null);
const gatewaysKey = getListGatewaysApiV1GatewaysGetQueryKey();
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
refetchOnMount: "always",
}
);
},
});
const gateways = useMemo(() => gatewaysQuery.data ?? [], [gatewaysQuery.data]);
const gateways = useMemo(() => gatewaysQuery.data?.data ?? [], [gatewaysQuery.data]);
const sortedGateways = useMemo(() => [...gateways], [gateways]);
const deleteMutation = useAuthedMutation<
void,
Gateway,
{ previous?: Gateway[] }
const deleteMutation = useDeleteGatewayApiV1GatewaysGatewayIdDelete<
ApiError,
{ previous?: listGatewaysApiV1GatewaysGetResponse }
>(
async (gateway, token) =>
apiRequest(`/api/v1/gateways/${gateway.id}`, {
method: "DELETE",
token,
}),
{
onMutate: async (gateway) => {
await queryClient.cancelQueries({ queryKey: ["gateways"] });
const previous = queryClient.getQueryData<Gateway[]>(["gateways"]);
queryClient.setQueryData<Gateway[]>(["gateways"], (old = []) =>
old.filter((item) => item.id !== gateway.id)
);
return { previous };
mutation: {
onMutate: async ({ gatewayId }) => {
await queryClient.cancelQueries({ queryKey: gatewaysKey });
const previous =
queryClient.getQueryData<listGatewaysApiV1GatewaysGetResponse>(gatewaysKey);
if (previous) {
queryClient.setQueryData<listGatewaysApiV1GatewaysGetResponse>(gatewaysKey, {
...previous,
data: previous.data.filter((gateway) => gateway.id !== gatewayId),
});
}
return { previous };
},
onError: (_error, _gateway, context) => {
if (context?.previous) {
queryClient.setQueryData(gatewaysKey, context.previous);
}
},
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: gatewaysKey });
},
},
onError: (_error, _gateway, context) => {
if (context?.previous) {
queryClient.setQueryData(["gateways"], context.previous);
}
},
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["gateways"] });
},
}
},
queryClient
);
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget);
deleteMutation.mutate({ gatewayId: deleteTarget.id });
};
const columns = useMemo<ColumnDef<Gateway>[]>(
const columns = useMemo<ColumnDef<GatewayRead>[]>(
() => [
{
accessorKey: "name",

View File

@@ -6,25 +6,19 @@ import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth, useUser } from "@clerk/nextjs";
import { Globe, Info, RotateCcw, Save, User } from "lucide-react";
import { ApiError } from "@/api/mutator";
import {
type getMeApiV1UsersMeGetResponse,
useGetMeApiV1UsersMeGet,
useUpdateMeApiV1UsersMePatch,
} from "@/api/generated/users/users";
import type { UserRead } from "@/api/generated/model";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import SearchableSelect from "@/components/ui/searchable-select";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase = getApiBaseUrl();
type UserProfile = {
id: string;
name?: string | null;
preferred_name?: string | null;
pronouns?: string | null;
timezone?: string | null;
notes?: string | null;
context?: string | null;
};
const isCompleteProfile = (profile: UserProfile | null) => {
const isCompleteProfile = (profile: UserRead | null | undefined) => {
if (!profile) return false;
const resolvedName = profile.preferred_name?.trim() || profile.name?.trim();
return Boolean(resolvedName) && Boolean(profile.timezone?.trim());
@@ -32,17 +26,51 @@ const isCompleteProfile = (profile: UserProfile | null) => {
export default function OnboardingPage() {
const router = useRouter();
const { getToken, isSignedIn } = useAuth();
const { isSignedIn } = useAuth();
const { user } = useUser();
const [name, setName] = useState("");
const [timezone, setTimezone] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const meQuery = useGetMeApiV1UsersMeGet<getMeApiV1UsersMeGetResponse, ApiError>(
{
query: {
enabled: Boolean(isSignedIn),
retry: false,
refetchOnMount: "always",
},
},
);
const updateMeMutation = useUpdateMeApiV1UsersMePatch<ApiError>({
mutation: {
onSuccess: () => {
router.replace("/dashboard");
},
onError: (err) => {
setError(err.message || "Something went wrong.");
},
},
});
const isLoading = meQuery.isLoading || updateMeMutation.isPending;
const loadError = meQuery.error?.message ?? null;
const errorMessage = error ?? loadError;
const profile = meQuery.data?.status === 200 ? meQuery.data.data : null;
const clerkFallbackName =
user?.fullName ?? user?.firstName ?? user?.username ?? "";
const resolvedName =
name.trim()
? name
: profile?.preferred_name ?? profile?.name ?? clerkFallbackName ?? "";
const resolvedTimezone =
timezone.trim() ? timezone : profile?.timezone ?? "";
const requiredMissing = useMemo(
() => [name, timezone].some((value) => !value.trim()),
[name, timezone]
() => [resolvedName, resolvedTimezone].some((value) => !value.trim()),
[resolvedName, resolvedTimezone],
);
const timezones = useMemo(() => {
@@ -73,47 +101,11 @@ export default function OnboardingPage() {
[timezones],
);
const loadProfile = async () => {
if (!isSignedIn) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/users/me`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load profile.");
}
const data = (await response.json()) as UserProfile;
const fallbackName =
user?.fullName ?? user?.firstName ?? user?.username ?? "";
setName(data.preferred_name ?? data.name ?? fallbackName);
setTimezone(data.timezone ?? "");
if (isCompleteProfile(data)) {
router.replace("/dashboard");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!name.trim() && user) {
const fallbackName =
user.fullName ?? user.firstName ?? user.username ?? "";
if (fallbackName) {
setName(fallbackName);
}
if (profile && isCompleteProfile(profile)) {
router.replace("/dashboard");
}
}, [user, name]);
useEffect(() => {
loadProfile();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
}, [profile, router]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -122,32 +114,17 @@ export default function OnboardingPage() {
setError("Please complete the required fields.");
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const normalizedName = name.trim();
const normalizedName = resolvedName.trim();
const payload = {
name: normalizedName,
preferred_name: normalizedName,
timezone: timezone.trim(),
timezone: resolvedTimezone.trim(),
};
const response = await fetch(`${apiBase}/api/v1/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("Unable to update profile.");
}
router.replace("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
await updateMeMutation.mutateAsync({ data: payload });
} catch {
// handled by onError
}
};
@@ -197,7 +174,7 @@ export default function OnboardingPage() {
<span className="text-red-500">*</span>
</label>
<Input
value={name}
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Enter your name"
disabled={isLoading}
@@ -212,7 +189,7 @@ export default function OnboardingPage() {
</label>
<SearchableSelect
ariaLabel="Select timezone"
value={timezone}
value={resolvedTimezone}
onValueChange={setTimezone}
options={timezoneOptions}
placeholder="Select timezone"
@@ -233,9 +210,9 @@ export default function OnboardingPage() {
</p>
</div>
{error ? (
{errorMessage ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600">
{error}
{errorMessage}
</div>
) : null}