feat: add board group models and update related interfaces

This commit is contained in:
Abhimanyu Saharan
2026-02-07 20:29:50 +05:30
parent 7b5ee230f5
commit 88a5075684
170 changed files with 12372 additions and 3697 deletions

View File

@@ -69,7 +69,7 @@ const getBoardOptions = (boards: BoardRead[]): SearchableSelectOption[] =>
}));
const normalizeIdentityProfile = (
profile: IdentityProfile
profile: IdentityProfile,
): IdentityProfile | null => {
const normalized: IdentityProfile = {
role: profile.role.trim(),
@@ -81,11 +81,12 @@ const normalizeIdentityProfile = (
};
const withIdentityDefaults = (
profile: Partial<IdentityProfile> | null | undefined
profile: Partial<IdentityProfile> | null | undefined,
): IdentityProfile => ({
role: profile?.role ?? DEFAULT_IDENTITY_PROFILE.role,
communication_style:
profile?.communication_style ?? DEFAULT_IDENTITY_PROFILE.communication_style,
profile?.communication_style ??
DEFAULT_IDENTITY_PROFILE.communication_style,
emoji: profile?.emoji ?? DEFAULT_IDENTITY_PROFILE.emoji,
});
@@ -150,8 +151,10 @@ export default function EditAgentPage() {
},
});
const boards =
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [];
const boards = useMemo<BoardRead[]>(() => {
if (boardsQuery.data?.status !== 200) return [];
return boardsQuery.data.data.items ?? [];
}, [boardsQuery.data]);
const loadedAgent: AgentRead | null =
agentQuery.data?.status === 200 ? agentQuery.data.data : null;
@@ -226,7 +229,7 @@ export default function EditAgentPage() {
!loadedAgent.board_id
) {
setError(
"Select a board once so we can resolve the gateway main session key."
"Select a board once so we can resolve the gateway main session key.",
);
return;
}
@@ -238,10 +241,9 @@ export default function EditAgentPage() {
every: resolvedHeartbeatEvery.trim() || "10m",
target: resolvedHeartbeatTarget,
} as unknown as Record<string, unknown>,
identity_profile: normalizeIdentityProfile(resolvedIdentityProfile) as unknown as Record<
string,
unknown
> | null,
identity_profile: normalizeIdentityProfile(
resolvedIdentityProfile,
) as unknown as Record<string, unknown> | null,
soul_template: resolvedSoulTemplate.trim() || null,
};
if (!resolvedIsGatewayMain) {
@@ -278,7 +280,9 @@ 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">
{resolvedName.trim() ? resolvedName : loadedAgent?.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.
@@ -356,7 +360,11 @@ export default function EditAgentPage() {
value={resolvedBoardId}
onValueChange={(value) => setBoardId(value)}
options={getBoardOptions(boards)}
placeholder={resolvedIsGatewayMain ? "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"
@@ -410,7 +418,9 @@ export default function EditAgentPage() {
type="checkbox"
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-200"
checked={resolvedIsGatewayMain}
onChange={(event) => setIsGatewayMain(event.target.checked)}
onChange={(event) =>
setIsGatewayMain(event.target.checked)
}
disabled={isLoading}
/>
<span>
@@ -471,7 +481,9 @@ export default function EditAgentPage() {
</label>
<Input
value={resolvedHeartbeatEvery}
onChange={(event) => setHeartbeatEvery(event.target.value)}
onChange={(event) =>
setHeartbeatEvery(event.target.value)
}
placeholder="e.g. 10m"
disabled={isLoading}
/>

View File

@@ -22,7 +22,11 @@ import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import type { ActivityEventRead, AgentRead, BoardRead } from "@/api/generated/model";
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";
@@ -119,12 +123,14 @@ export default function AgentDetailPage() {
const agent: AgentRead | null =
agentQuery.data?.status === 200 ? agentQuery.data.data : null;
const events: ActivityEventRead[] =
activityQuery.data?.status === 200
? activityQuery.data.data.items ?? []
: [];
const boards: BoardRead[] =
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [];
const events = useMemo<ActivityEventRead[]>(() => {
if (activityQuery.data?.status !== 200) return [];
return activityQuery.data.data.items ?? [];
}, [activityQuery.data]);
const boards = useMemo<BoardRead[]>(() => {
if (boardsQuery.data?.status !== 200) return [];
return boardsQuery.data.data.items ?? [];
}, [boardsQuery.data]);
const agentEvents = useMemo(() => {
if (!agent) return [];
@@ -133,7 +139,7 @@ export default function AgentDetailPage() {
const linkedBoard =
!agent?.board_id || agent?.is_gateway_main
? null
: boards.find((board) => board.id === agent.board_id) ?? null;
: (boards.find((board) => board.id === agent.board_id) ?? null);
const deleteMutation = useDeleteAgentApiV1AgentsAgentIdDelete<ApiError>({
mutation: {
@@ -194,8 +200,7 @@ export default function AgentDetailPage() {
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.push("/agents")}
>
<Button variant="outline" onClick={() => router.push("/agents")}>
Back to agents
</Button>
{agent ? (
@@ -259,7 +264,9 @@ export default function AgentDetailPage() {
Board
</p>
{agent.is_gateway_main ? (
<p className="mt-1 text-sm text-strong">Gateway main (no board)</p>
<p className="mt-1 text-sm text-strong">
Gateway main (no board)
</p>
) : linkedBoard ? (
<Link
href={`/boards/${linkedBoard.id}`}
@@ -315,7 +322,9 @@ export default function AgentDetailPage() {
</div>
<div className="flex items-center justify-between">
<span>Session binding</span>
<span>{agent.openclaw_session_id ? "Bound" : "Unbound"}</span>
<span>
{agent.openclaw_session_id ? "Bound" : "Unbound"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Status</span>

View File

@@ -65,7 +65,7 @@ const getBoardOptions = (boards: BoardRead[]): SearchableSelectOption[] =>
}));
const normalizeIdentityProfile = (
profile: IdentityProfile
profile: IdentityProfile,
): IdentityProfile | null => {
const normalized: IdentityProfile = {
role: profile.role.trim(),
@@ -114,7 +114,7 @@ export default function NewAgentPage() {
});
const boards =
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [];
boardsQuery.data?.status === 200 ? (boardsQuery.data.data.items ?? []) : [];
const displayBoardId = boardId || boards[0]?.id || "";
const isLoading = boardsQuery.isLoading || createAgentMutation.isPending;
const errorMessage = error ?? boardsQuery.error?.message ?? null;
@@ -141,10 +141,9 @@ export default function NewAgentPage() {
every: heartbeatEvery.trim() || "10m",
target: heartbeatTarget,
},
identity_profile: normalizeIdentityProfile(identityProfile) as unknown as Record<
string,
unknown
> | null,
identity_profile: normalizeIdentityProfile(
identityProfile,
) as unknown as Record<string, unknown> | null,
soul_template: soulTemplate.trim() || null,
},
});
@@ -155,7 +154,9 @@ export default function NewAgentPage() {
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">Sign in to create an agent.</p>
<p className="text-sm text-slate-600">
Sign in to create an agent.
</p>
<SignInButton
mode="modal"
forceRedirectUrl="/agents/new"
@@ -318,7 +319,9 @@ export default function NewAgentPage() {
</label>
<Input
value={heartbeatEvery}
onChange={(event) => setHeartbeatEvery(event.target.value)}
onChange={(event) =>
setHeartbeatEvery(event.target.value)
}
placeholder="e.g. 10m"
disabled={isLoading}
/>

View File

@@ -42,7 +42,7 @@ import {
getListBoardsApiV1BoardsGetQueryKey,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import type { AgentRead, BoardRead } from "@/api/generated/model";
import type { AgentRead } from "@/api/generated/model";
const parseTimestamp = (value?: string | null) => {
if (!value) return null;
@@ -121,13 +121,17 @@ export default function AgentsPage() {
const boards = useMemo(
() =>
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [],
[boardsQuery.data]
boardsQuery.data?.status === 200
? (boardsQuery.data.data.items ?? [])
: [],
[boardsQuery.data],
);
const agents = useMemo(
() =>
agentsQuery.data?.status === 200 ? agentsQuery.data.data.items ?? [] : [],
[agentsQuery.data]
agentsQuery.data?.status === 200
? (agentsQuery.data.data.items ?? [])
: [],
[agentsQuery.data],
);
const deleteMutation = useDeleteAgentApiV1AgentsAgentIdDelete<
@@ -139,20 +143,25 @@ export default function AgentsPage() {
onMutate: async ({ agentId }) => {
await queryClient.cancelQueries({ queryKey: agentsKey });
const previous =
queryClient.getQueryData<listAgentsApiV1AgentsGetResponse>(agentsKey);
queryClient.getQueryData<listAgentsApiV1AgentsGetResponse>(
agentsKey,
);
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(agent) => agent.id !== agentId
(agent) => agent.id !== agentId,
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listAgentsApiV1AgentsGetResponse>(agentsKey, {
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
queryClient.setQueryData<listAgentsApiV1AgentsGetResponse>(
agentsKey,
{
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
},
});
);
}
return { previous };
},
@@ -170,103 +179,99 @@ export default function AgentsPage() {
},
},
},
queryClient
queryClient,
);
const sortedAgents = useMemo(() => [...agents], [agents]);
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate({ agentId: deleteTarget.id });
};
const columns = useMemo<ColumnDef<AgentRead>[]>(
() => {
const resolveBoardName = (agent: AgentRead) =>
boards.find((board) => board.id === agent.board_id)?.name ?? "—";
const columns = useMemo<ColumnDef<AgentRead>[]>(() => {
const resolveBoardName = (agent: AgentRead) =>
boards.find((board) => board.id === agent.board_id)?.name ?? "—";
return [
{
accessorKey: "name",
header: "Agent",
cell: ({ row }) => (
<Link href={`/agents/${row.original.id}`} className="group block">
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
{row.original.name}
</p>
<p className="text-xs text-slate-500">ID {row.original.id}</p>
return [
{
accessorKey: "name",
header: "Agent",
cell: ({ row }) => (
<Link href={`/agents/${row.original.id}`} className="group block">
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
{row.original.name}
</p>
<p className="text-xs text-slate-500">ID {row.original.id}</p>
</Link>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<StatusPill status={row.original.status ?? "unknown"} />
),
},
{
accessorKey: "openclaw_session_id",
header: "Session",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{truncate(row.original.openclaw_session_id)}
</span>
),
},
{
accessorKey: "board_id",
header: "Board",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{resolveBoardName(row.original)}
</span>
),
},
{
accessorKey: "last_seen_at",
header: "Last seen",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatRelative(row.original.last_seen_at)}
</span>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatTimestamp(row.original.updated_at)}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex justify-end gap-2">
<Link
href={`/agents/${row.original.id}/edit`}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<StatusPill status={row.original.status ?? "unknown"} />
),
},
{
accessorKey: "openclaw_session_id",
header: "Session",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{truncate(row.original.openclaw_session_id)}
</span>
),
},
{
accessorKey: "board_id",
header: "Board",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{resolveBoardName(row.original)}
</span>
),
},
{
accessorKey: "last_seen_at",
header: "Last seen",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatRelative(row.original.last_seen_at)}
</span>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatTimestamp(row.original.updated_at)}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex justify-end gap-2">
<Link
href={`/agents/${row.original.id}/edit`}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
];
},
[boards]
);
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
];
}, [boards]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
@@ -330,7 +335,7 @@ export default function AgentsPage() {
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
header.getContext(),
)}
</th>
))}
@@ -341,7 +346,9 @@ export default function AgentsPage() {
{agentsQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">Loading</span>
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
@@ -351,7 +358,7 @@ export default function AgentsPage() {
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
cell.getContext(),
)}
</td>
))}
@@ -386,7 +393,10 @@ export default function AgentsPage() {
</p>
<Link
href="/agents/new"
className={buttonVariants({ size: "md", variant: "primary" })}
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first agent
</Link>
@@ -420,7 +430,8 @@ export default function AgentsPage() {
<DialogHeader>
<DialogTitle>Delete agent</DialogTitle>
<DialogDescription>
This will remove {deleteTarget?.name}. This action cannot be undone.
This will remove {deleteTarget?.name}. This action cannot be
undone.
</DialogDescription>
</DialogHeader>
{deleteMutation.error ? (

View File

@@ -0,0 +1,483 @@
"use client";
export const dynamic = "force-dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
type listBoardsApiV1BoardsGetResponse,
updateBoardApiV1BoardsBoardIdPatch,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
type getBoardGroupApiV1BoardGroupsGroupIdGetResponse,
useGetBoardGroupApiV1BoardGroupsGroupIdGet,
useUpdateBoardGroupApiV1BoardGroupsGroupIdPatch,
} from "@/api/generated/board-groups/board-groups";
import type {
BoardGroupRead,
BoardGroupUpdate,
BoardRead,
} 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 { Textarea } from "@/components/ui/textarea";
const slugify = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "group";
export default function EditBoardGroupPage() {
const { isSignedIn } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const params = useParams();
const groupIdParam = params?.groupId;
const groupId = Array.isArray(groupIdParam) ? groupIdParam[0] : groupIdParam;
const [name, setName] = useState<string | undefined>(undefined);
const [description, setDescription] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [boardSearch, setBoardSearch] = useState("");
const [selectedBoardIds, setSelectedBoardIds] = useState<Set<string>>(
() => new Set(),
);
const [isAssignmentsSaving, setIsAssignmentsSaving] = useState(false);
const [assignmentsError, setAssignmentsError] = useState<string | null>(null);
const [assignmentsResult, setAssignmentsResult] = useState<{
updated: number;
failed: number;
} | null>(null);
const assignFailedParam = searchParams.get("assign_failed");
const assignFailedCount = assignFailedParam
? Number.parseInt(assignFailedParam, 10)
: null;
const groupQuery = useGetBoardGroupApiV1BoardGroupsGroupIdGet<
getBoardGroupApiV1BoardGroupsGroupIdGetResponse,
ApiError
>(groupId ?? "", {
query: {
enabled: Boolean(isSignedIn && groupId),
refetchOnMount: "always",
retry: false,
},
});
const loadedGroup: BoardGroupRead | null =
groupQuery.data?.status === 200 ? groupQuery.data.data : null;
const baseGroup = loadedGroup;
const resolvedName = name ?? baseGroup?.name ?? "";
const resolvedDescription = description ?? baseGroup?.description ?? "";
const allBoardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(
{ limit: 200 },
{
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
},
);
const groupBoardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(
{ limit: 200, board_group_id: groupId ?? null },
{
query: {
enabled: Boolean(isSignedIn && groupId),
refetchOnMount: "always",
retry: false,
},
},
);
const allBoards = useMemo<BoardRead[]>(() => {
if (allBoardsQuery.data?.status !== 200) return [];
return allBoardsQuery.data.data.items ?? [];
}, [allBoardsQuery.data]);
const groupBoards = useMemo<BoardRead[]>(() => {
if (groupBoardsQuery.data?.status !== 200) return [];
return groupBoardsQuery.data.data.items ?? [];
}, [groupBoardsQuery.data]);
const boards = useMemo<BoardRead[]>(() => {
const byId = new Map<string, BoardRead>();
for (const board of allBoards) {
byId.set(board.id, board);
}
for (const board of groupBoards) {
byId.set(board.id, board);
}
const merged = Array.from(byId.values());
merged.sort((a, b) => a.name.localeCompare(b.name));
return merged;
}, [allBoards, groupBoards]);
const initializedSelectionRef = useRef(false);
useEffect(() => {
if (!groupId) return;
if (initializedSelectionRef.current) return;
if (groupBoardsQuery.data?.status !== 200) return;
initializedSelectionRef.current = true;
setSelectedBoardIds(new Set(groupBoards.map((board) => board.id)));
}, [groupBoards, groupBoardsQuery.data, groupId]);
const updateMutation =
useUpdateBoardGroupApiV1BoardGroupsGroupIdPatch<ApiError>({
mutation: {
retry: false,
},
});
const isGroupSaving = groupQuery.isLoading || updateMutation.isPending;
const boardsLoading = allBoardsQuery.isLoading || groupBoardsQuery.isLoading;
const boardsError = groupBoardsQuery.error ?? allBoardsQuery.error ?? null;
const isBoardsBusy = boardsLoading || isAssignmentsSaving;
const isLoading = isGroupSaving || isBoardsBusy;
const errorMessage = error ?? groupQuery.error?.message ?? null;
const isFormReady = Boolean(resolvedName.trim());
const handleSaveAssignments = async (): Promise<{
updated: number;
failed: number;
} | null> => {
if (!isSignedIn || !groupId) return null;
if (groupBoardsQuery.data?.status !== 200) {
setAssignmentsError("Group boards are not loaded yet.");
return null;
}
setAssignmentsError(null);
setAssignmentsResult(null);
const desired = selectedBoardIds;
const current = new Set(groupBoards.map((board) => board.id));
const toAdd = Array.from(desired).filter((id) => !current.has(id));
const toRemove = Array.from(current).filter((id) => !desired.has(id));
const failures: string[] = [];
let updated = 0;
for (const boardId of toAdd) {
try {
const result = await updateBoardApiV1BoardsBoardIdPatch(boardId, {
board_group_id: groupId,
});
if (result.status === 200) {
updated += 1;
} else {
failures.push(boardId);
}
} catch {
failures.push(boardId);
}
}
for (const boardId of toRemove) {
try {
const result = await updateBoardApiV1BoardsBoardIdPatch(boardId, {
board_group_id: null,
});
if (result.status === 200) {
updated += 1;
} else {
failures.push(boardId);
}
} catch {
failures.push(boardId);
}
}
setAssignmentsResult({ updated, failed: failures.length });
if (failures.length > 0) {
setAssignmentsError(
`Failed to update ${failures.length} board assignment${
failures.length === 1 ? "" : "s"
}.`,
);
}
void groupBoardsQuery.refetch();
void allBoardsQuery.refetch();
return { updated, failed: failures.length };
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn || !groupId) return;
const trimmedName = resolvedName.trim();
if (!trimmedName) {
setError("Group name is required.");
return;
}
setError(null);
setAssignmentsError(null);
setAssignmentsResult(null);
const payload: BoardGroupUpdate = {
name: trimmedName,
slug: slugify(trimmedName),
description: resolvedDescription.trim() || null,
};
setIsAssignmentsSaving(true);
try {
const result = await updateMutation.mutateAsync({
groupId,
data: payload,
});
if (result.status !== 200) {
setError("Something went wrong.");
return;
}
const assignments = await handleSaveAssignments();
if (!assignments || assignments.failed > 0) {
return;
}
router.push(`/board-groups/${result.data.id}`);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: err instanceof Error
? err.message
: null;
setError(message || "Something went wrong.");
} finally {
setIsAssignmentsSaving(false);
}
};
const title = useMemo(
() => baseGroup?.name ?? "Edit group",
[baseGroup?.name],
);
return (
<DashboardShell>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">
Sign in to edit board groups.
</p>
<SignInButton
mode="modal"
forceRedirectUrl={`/board-groups/${groupId ?? ""}/edit`}
>
<Button className="mt-4">Sign in</Button>
</SignInButton>
</div>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main className="flex-1 overflow-y-auto bg-slate-50">
<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">
{title}
</h1>
<p className="mt-1 text-sm text-slate-500">
Update the shared context that connects boards in this group.
</p>
</div>
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
{assignFailedCount && Number.isFinite(assignFailedCount) ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 shadow-sm">
Group was created, but {assignFailedCount} board assignment
{assignFailedCount === 1 ? "" : "s"} failed. You can retry
below.
</div>
) : null}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Group name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Group name"
disabled={isLoading || !baseGroup}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Description
</label>
<Textarea
value={resolvedDescription}
onChange={(event) => setDescription(event.target.value)}
placeholder="What ties these boards together?"
className="min-h-[120px]"
disabled={isLoading || !baseGroup}
/>
</div>
<div className="space-y-2 border-t border-slate-100 pt-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium text-slate-900">Boards</p>
<p className="mt-1 text-xs text-slate-500">
Assign boards to this group to share context across
related work.
</p>
</div>
<span className="text-xs text-slate-500">
{selectedBoardIds.size} selected
</span>
</div>
<Input
value={boardSearch}
onChange={(event) => setBoardSearch(event.target.value)}
placeholder="Search boards..."
disabled={isLoading || !baseGroup}
/>
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
{boardsLoading && boards.length === 0 ? (
<div className="px-4 py-6 text-sm text-slate-500">
Loading boards
</div>
) : boardsError ? (
<div className="px-4 py-6 text-sm text-rose-700">
{boardsError.message}
</div>
) : boards.length === 0 ? (
<div className="px-4 py-6 text-sm text-slate-500">
No boards found.
</div>
) : (
<ul className="divide-y divide-slate-200">
{boards
.filter((board) => {
const q = boardSearch.trim().toLowerCase();
if (!q) return true;
return (
board.name.toLowerCase().includes(q) ||
board.slug.toLowerCase().includes(q)
);
})
.map((board) => {
const checked = selectedBoardIds.has(board.id);
const isInThisGroup =
board.board_group_id === groupId;
const isAlreadyGrouped =
Boolean(board.board_group_id) && !isInThisGroup;
return (
<li key={board.id} className="px-4 py-3">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
checked={checked}
onChange={() => {
setSelectedBoardIds((prev) => {
const next = new Set(prev);
if (next.has(board.id)) {
next.delete(board.id);
} else {
next.add(board.id);
}
return next;
});
}}
disabled={isLoading || !baseGroup}
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-900">
{board.name}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="font-mono text-[11px] text-slate-400">
{board.id}
</span>
{isAlreadyGrouped ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-900">
in another group
</span>
) : null}
</div>
</div>
</label>
</li>
);
})}
</ul>
)}
</div>
{assignmentsError ? (
<p className="text-sm text-rose-700">{assignmentsError}</p>
) : null}
{assignmentsResult ? (
<p className="text-sm text-slate-700">
Updated {assignmentsResult.updated} board
{assignmentsResult.updated === 1 ? "" : "s"}, failed{" "}
{assignmentsResult.failed}.
</p>
) : null}
</div>
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/board-groups/${groupId ?? ""}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || !baseGroup || !isFormReady}
>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</div>
</main>
</SignedIn>
</DashboardShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,311 @@
"use client";
export const dynamic = "force-dynamic";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
type listBoardsApiV1BoardsGetResponse,
updateBoardApiV1BoardsBoardIdPatch,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import { useCreateBoardGroupApiV1BoardGroupsPost } from "@/api/generated/board-groups/board-groups";
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";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
const slugify = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "group";
export default function NewBoardGroupPage() {
const router = useRouter();
const { isSignedIn } = useAuth();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [boardSearch, setBoardSearch] = useState("");
const [selectedBoardIds, setSelectedBoardIds] = useState<Set<string>>(
() => new Set(),
);
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(
{ limit: 200 },
{
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
},
);
const boards: BoardRead[] =
boardsQuery.data?.status === 200 ? (boardsQuery.data.data.items ?? []) : [];
const createMutation = useCreateBoardGroupApiV1BoardGroupsPost<ApiError>({
mutation: {
onError: (err) => {
setError(err.message || "Something went wrong.");
},
},
});
const isCreating = createMutation.isPending;
const isFormReady = Boolean(name.trim());
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn) return;
const trimmedName = name.trim();
if (!trimmedName) {
setError("Group name is required.");
return;
}
setError(null);
try {
const created = await createMutation.mutateAsync({
data: {
name: trimmedName,
slug: slugify(trimmedName),
description: description.trim() || null,
},
});
if (created.status !== 200) {
throw new Error("Unable to create group.");
}
const groupId = created.data.id;
const boardIds = Array.from(selectedBoardIds);
if (boardIds.length) {
const failures: string[] = [];
for (const boardId of boardIds) {
try {
const result = await updateBoardApiV1BoardsBoardIdPatch(boardId, {
board_group_id: groupId,
});
if (result.status !== 200) {
failures.push(boardId);
}
} catch {
failures.push(boardId);
}
}
if (failures.length) {
router.push(
`/board-groups/${groupId}/edit?assign_failed=${failures.length}`,
);
return;
}
}
router.push(`/board-groups/${groupId}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
}
};
return (
<DashboardShell>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">
Sign in to create a board group.
</p>
<SignInButton mode="modal" forceRedirectUrl="/board-groups/new">
<Button className="mt-4">Sign in</Button>
</SignInButton>
</div>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main className="flex-1 overflow-y-auto bg-slate-50">
<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">
Create board group
</h1>
<p className="mt-1 text-sm text-slate-500">
Groups help agents discover related work across boards.
</p>
</div>
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Group name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Release hardening"
disabled={isCreating}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Description
</label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="What ties these boards together? What should agents coordinate on?"
className="min-h-[120px]"
disabled={isCreating}
/>
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-slate-900">
Boards
</label>
<span className="text-xs text-slate-500">
{selectedBoardIds.size} selected
</span>
</div>
<Input
value={boardSearch}
onChange={(event) => setBoardSearch(event.target.value)}
placeholder="Search boards..."
disabled={isCreating}
/>
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
{boardsQuery.isLoading ? (
<div className="px-4 py-6 text-sm text-slate-500">
Loading boards
</div>
) : boardsQuery.error ? (
<div className="px-4 py-6 text-sm text-rose-700">
{boardsQuery.error.message}
</div>
) : boards.length === 0 ? (
<div className="px-4 py-6 text-sm text-slate-500">
No boards found.
</div>
) : (
<ul className="divide-y divide-slate-200">
{boards
.filter((board) => {
const q = boardSearch.trim().toLowerCase();
if (!q) return true;
return (
board.name.toLowerCase().includes(q) ||
board.slug.toLowerCase().includes(q)
);
})
.map((board) => {
const checked = selectedBoardIds.has(board.id);
const isAlreadyGrouped = Boolean(
board.board_group_id,
);
return (
<li key={board.id} className="px-4 py-3">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
checked={checked}
onChange={() => {
setSelectedBoardIds((prev) => {
const next = new Set(prev);
if (next.has(board.id)) {
next.delete(board.id);
} else {
next.add(board.id);
}
return next;
});
}}
disabled={isCreating}
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-900">
{board.name}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="font-mono text-[11px] text-slate-400">
{board.id}
</span>
{isAlreadyGrouped ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-900">
currently grouped
</span>
) : null}
</div>
</div>
</label>
</li>
);
})}
</ul>
)}
</div>
<p className="text-xs text-slate-500">
Optional. Selected boards will be assigned to this group after
creation. You can change membership later in group edit or
board settings.
</p>
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/board-groups")}
disabled={isCreating}
>
Cancel
</Button>
<Button type="submit" disabled={isCreating || !isFormReady}>
{isCreating ? "Creating…" : "Create group"}
</Button>
</div>
<div className="border-t border-slate-100 pt-4 text-xs text-slate-500">
Want to assign boards later? Update each board in{" "}
<Link
href="/boards"
className="font-medium text-blue-600 hover:text-blue-700"
>
Boards
</Link>{" "}
and pick this group.
</div>
</form>
</div>
</main>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -0,0 +1,365 @@
"use client";
export const dynamic = "force-dynamic";
import { useMemo, useState } from "react";
import Link from "next/link";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
getListBoardGroupsApiV1BoardGroupsGetQueryKey,
useDeleteBoardGroupApiV1BoardGroupsGroupIdDelete,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
import type { BoardGroupRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const formatTimestamp = (value?: string | null) => {
if (!value) return "—";
const date = new Date(`${value}${value.endsWith("Z") ? "" : "Z"}`);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
export default function BoardGroupsPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = useState<BoardGroupRead | null>(null);
const groupsKey = getListBoardGroupsApiV1BoardGroupsGetQueryKey();
const groupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
listBoardGroupsApiV1BoardGroupsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
refetchOnMount: "always",
},
});
const groups = useMemo(
() =>
groupsQuery.data?.status === 200
? (groupsQuery.data.data.items ?? [])
: [],
[groupsQuery.data],
);
const deleteMutation = useDeleteBoardGroupApiV1BoardGroupsGroupIdDelete<
ApiError,
{ previous?: listBoardGroupsApiV1BoardGroupsGetResponse }
>(
{
mutation: {
onMutate: async ({ groupId }) => {
await queryClient.cancelQueries({ queryKey: groupsKey });
const previous =
queryClient.getQueryData<listBoardGroupsApiV1BoardGroupsGetResponse>(
groupsKey,
);
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(group) => group.id !== groupId,
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listBoardGroupsApiV1BoardGroupsGetResponse>(
groupsKey,
{
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
},
);
}
return { previous };
},
onError: (_error, _group, context) => {
if (context?.previous) {
queryClient.setQueryData(groupsKey, context.previous);
}
},
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: groupsKey });
},
},
},
queryClient,
);
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate({ groupId: deleteTarget.id });
};
const columns = useMemo<ColumnDef<BoardGroupRead>[]>(
() => [
{
accessorKey: "name",
header: "Group",
cell: ({ row }) => (
<Link
href={`/board-groups/${row.original.id}`}
className="group block"
>
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
{row.original.name}
</p>
{row.original.description ? (
<p className="mt-1 text-xs text-slate-500 line-clamp-2">
{row.original.description}
</p>
) : (
<p className="mt-1 text-xs text-slate-400">No description</p>
)}
</Link>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatTimestamp(row.original.updated_at)}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex items-center justify-end gap-2">
<Link
href={`/board-groups/${row.original.id}/edit`}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
],
[],
);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: groups,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<DashboardShell>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">
Sign in to view board groups.
</p>
<SignInButton mode="modal" forceRedirectUrl="/board-groups">
<Button className="mt-4">Sign in</Button>
</SignInButton>
</div>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main className="flex-1 overflow-y-auto bg-slate-50">
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white">
<div className="px-8 py-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
Board groups
</h1>
<p className="mt-1 text-sm text-slate-500">
Group boards so agents can see related work. {groups.length}{" "}
group{groups.length === 1 ? "" : "s"} total.
</p>
</div>
<Link
href="/board-groups/new"
className={buttonVariants({ size: "md", variant: "primary" })}
>
Create group
</Link>
</div>
</div>
</div>
<div className="p-8">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left font-semibold"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{groupsQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="transition hover:bg-slate-50"
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 align-top">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-6 py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-slate-50 p-4">
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 7h8" />
<path d="M3 17h8" />
<path d="M13 7h8" />
<path d="M13 17h8" />
<path d="M3 12h18" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No groups yet
</h3>
<p className="mb-6 max-w-md text-sm text-slate-500">
Create a board group to increase cross-board
visibility for agents.
</p>
<Link
href="/board-groups/new"
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first group
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{groupsQuery.error ? (
<p className="mt-4 text-sm text-red-500">
{groupsQuery.error.message}
</p>
) : null}
</div>
</main>
</SignedIn>
<Dialog
open={!!deleteTarget}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDeleteTarget(null);
}
}}
>
<DialogContent aria-label="Delete board group">
<DialogHeader>
<DialogTitle>Delete board group</DialogTitle>
<DialogDescription>
This will remove {deleteTarget?.name}. Boards will be ungrouped.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
{deleteMutation.error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{deleteMutation.error.message}
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button onClick={handleDelete} disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardShell>
);
}

View File

@@ -14,11 +14,19 @@ import {
useGetBoardApiV1BoardsBoardIdGet,
useUpdateBoardApiV1BoardsBoardIdPatch,
} from "@/api/generated/boards/boards";
import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { BoardRead, BoardUpdate } from "@/api/generated/model";
import type {
BoardGroupRead,
BoardRead,
BoardUpdate,
} from "@/api/generated/model";
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -56,6 +64,9 @@ export default function EditBoardPage() {
const [board, setBoard] = useState<BoardRead | null>(null);
const [name, setName] = useState<string | undefined>(undefined);
const [gatewayId, setGatewayId] = useState<string | undefined>(undefined);
const [boardGroupId, setBoardGroupId] = useState<string | undefined>(
undefined,
);
const [boardType, setBoardType] = useState<string | undefined>(undefined);
const [objective, setObjective] = useState<string | undefined>(undefined);
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
@@ -74,7 +85,9 @@ export default function EditBoardPage() {
onboardingParam !== "0" &&
onboardingParam.toLowerCase() !== "false";
const [isOnboardingOpen, setIsOnboardingOpen] = useState(shouldAutoOpenOnboarding);
const [isOnboardingOpen, setIsOnboardingOpen] = useState(
shouldAutoOpenOnboarding,
);
useEffect(() => {
if (!isOnboardingOpen) return;
@@ -107,7 +120,9 @@ export default function EditBoardPage() {
const nextParams = new URLSearchParams(searchParamsString);
nextParams.delete("onboarding");
const qs = nextParams.toString();
router.replace(qs ? `/boards/${boardId}/edit?${qs}` : `/boards/${boardId}/edit`);
router.replace(
qs ? `/boards/${boardId}/edit?${qs}` : `/boards/${boardId}/edit`,
);
}, [boardId, router, searchParamsString, shouldAutoOpenOnboarding]);
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
@@ -121,6 +136,17 @@ export default function EditBoardPage() {
},
});
const groupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
listBoardGroupsApiV1BoardGroupsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const boardQuery = useGetBoardApiV1BoardsBoardIdGet<
getBoardApiV1BoardsBoardIdGetResponse,
ApiError
@@ -145,16 +171,18 @@ export default function EditBoardPage() {
},
});
const gateways =
gatewaysQuery.data?.status === 200
? gatewaysQuery.data.data.items ?? []
: [];
const gateways = useMemo(() => {
if (gatewaysQuery.data?.status !== 200) return [];
return gatewaysQuery.data.data.items ?? [];
}, [gatewaysQuery.data]);
const loadedBoard: BoardRead | null =
boardQuery.data?.status === 200 ? boardQuery.data.data : null;
const baseBoard = board ?? loadedBoard;
const resolvedName = name ?? baseBoard?.name ?? "";
const resolvedGatewayId = gatewayId ?? baseBoard?.gateway_id ?? "";
const resolvedBoardGroupId =
boardGroupId ?? baseBoard?.board_group_id ?? "none";
const resolvedBoardType = boardType ?? baseBoard?.board_type ?? "goal";
const resolvedObjective = objective ?? baseBoard?.objective ?? "";
const resolvedSuccessMetrics =
@@ -168,28 +196,48 @@ export default function EditBoardPage() {
const displayGatewayId = resolvedGatewayId || gateways[0]?.id || "";
const isLoading =
gatewaysQuery.isLoading || boardQuery.isLoading || updateBoardMutation.isPending;
gatewaysQuery.isLoading ||
groupsQuery.isLoading ||
boardQuery.isLoading ||
updateBoardMutation.isPending;
const errorMessage =
error ??
gatewaysQuery.error?.message ??
groupsQuery.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.map((gateway) => ({ value: gateway.id, label: gateway.name })),
[gateways],
);
const groups = useMemo<BoardGroupRead[]>(() => {
if (groupsQuery.data?.status !== 200) return [];
return groupsQuery.data.data.items ?? [];
}, [groupsQuery.data]);
const groupOptions = useMemo(
() => [
{ value: "none", label: "No group" },
...groups.map((group) => ({ value: group.id, label: group.name })),
],
[groups],
);
const 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(toLocalDateInput(updated.target_date));
setBoardGroupId(updated.board_group_id ?? "none");
setIsOnboardingOpen(false);
};
@@ -213,7 +261,10 @@ export default function EditBoardPage() {
let parsedMetrics: Record<string, unknown> | null = null;
if (resolvedSuccessMetrics.trim()) {
try {
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<string, unknown>;
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<
string,
unknown
>;
} catch {
setMetricsError("Success metrics must be valid JSON.");
return;
@@ -224,6 +275,8 @@ export default function EditBoardPage() {
name: trimmedName,
slug: slugify(trimmedName),
gateway_id: resolvedGatewayId || null,
board_group_id:
resolvedBoardGroupId === "none" ? null : resolvedBoardGroupId,
board_type: resolvedBoardType,
objective: resolvedObjective.trim() || null,
success_metrics: parsedMetrics,
@@ -236,182 +289,215 @@ export default function EditBoardPage() {
return (
<>
<DashboardShell>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">Sign in to edit boards.</p>
<SignInButton
mode="modal"
forceRedirectUrl={`/boards/${boardId}/edit`}
signUpForceRedirectUrl={`/boards/${boardId}/edit`}
>
<Button className="mt-4">Sign in</Button>
</SignInButton>
</div>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main ref={mainRef} className="flex-1 overflow-y-auto bg-slate-50">
<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">
Edit board
</h1>
<p className="mt-1 text-sm text-slate-500">
Update board settings and gateway.
</p>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">Sign in to edit boards.</p>
<SignInButton
mode="modal"
forceRedirectUrl={`/boards/${boardId}/edit`}
signUpForceRedirectUrl={`/boards/${boardId}/edit`}
>
<Button className="mt-4">Sign in</Button>
</SignInButton>
</div>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main ref={mainRef} className="flex-1 overflow-y-auto bg-slate-50">
<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">
Edit board
</h1>
<p className="mt-1 text-sm text-slate-500">
Update board settings and gateway.
</p>
</div>
</div>
<div className="p-8">
<div className="space-y-6">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
{resolvedBoardType !== "general" &&
baseBoard &&
!(baseBoard.goal_confirmed ?? false) ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-amber-900">
Goal needs confirmation
</p>
<p className="mt-1 text-xs text-amber-800/80">
Start onboarding to draft an objective and success
metrics.
<div className="p-8">
<div className="space-y-6">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
{resolvedBoardType !== "general" &&
baseBoard &&
!(baseBoard.goal_confirmed ?? false) ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-amber-900">
Goal needs confirmation
</p>
<p className="mt-1 text-xs text-amber-800/80">
Start onboarding to draft an objective and success
metrics.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={() => setIsOnboardingOpen(true)}
disabled={isLoading || !baseBoard}
>
Start onboarding
</Button>
</div>
) : null}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !baseBoard}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="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"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select
value={resolvedBoardType}
onValueChange={setBoardType}
>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board group
</label>
<SearchableSelect
ariaLabel="Select board group"
value={resolvedBoardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
searchPlaceholder="Search groups..."
emptyMessage="No groups found."
triggerClassName="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"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Boards in the same group can share cross-board context
for agents.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={() => setIsOnboardingOpen(true)}
disabled={isLoading || !baseBoard}
>
Start onboarding
</Button>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={resolvedTargetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
</div>
) : null}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !baseBoard}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="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"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
Objective
</label>
<Select value={resolvedBoardType} onValueChange={setBoardType}>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={resolvedTargetDate}
onChange={(event) => setTargetDate(event.target.value)}
<Textarea
value={resolvedObjective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Objective
</label>
<Textarea
value={resolvedObjective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={resolvedSuccessMetrics}
onChange={(event) => setSuccessMetrics(event.target.value)}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>No gateways available. Create one in Gateways to continue.</p>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={resolvedSuccessMetrics}
onChange={(event) =>
setSuccessMetrics(event.target.value)
}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
) : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>
No gateways available. Create one in Gateways to
continue.
</p>
</div>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !baseBoard || !isFormReady}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || !baseBoard || !isFormReady}
>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</div>
</div>
</div>
</main>
</SignedIn>
</main>
</SignedIn>
</DashboardShell>
<Dialog open={isOnboardingOpen} onOpenChange={setIsOnboardingOpen}>
<DialogContent

View File

@@ -54,7 +54,10 @@ import {
listTaskCommentFeedApiV1ActivityTaskCommentsGet,
streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet,
} from "@/api/generated/activity/activity";
import { getBoardSnapshotApiV1BoardsBoardIdSnapshotGet } from "@/api/generated/boards/boards";
import {
getBoardGroupSnapshotApiV1BoardsBoardIdGroupSnapshotGet,
getBoardSnapshotApiV1BoardsBoardIdSnapshotGet,
} from "@/api/generated/boards/boards";
import {
createBoardMemoryApiV1BoardsBoardIdMemoryPost,
streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet,
@@ -70,6 +73,7 @@ import {
import type {
AgentRead,
ApprovalRead,
BoardGroupSnapshot,
BoardMemoryRead,
BoardRead,
TaskCardRead,
@@ -321,6 +325,12 @@ export default function BoardDetailPage() {
const [board, setBoard] = useState<Board | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [groupSnapshot, setGroupSnapshot] = useState<BoardGroupSnapshot | null>(
null,
);
const [groupSnapshotError, setGroupSnapshotError] = useState<string | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
@@ -379,7 +389,9 @@ export default function BoardDetailPage() {
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
const isLiveFeedOpenRef = useRef(false);
const pushLiveFeed = useCallback((comment: TaskComment) => {
const alreadySeen = liveFeedRef.current.some((item) => item.id === comment.id);
const alreadySeen = liveFeedRef.current.some(
(item) => item.id === comment.id,
);
setLiveFeed((prev) => {
if (prev.some((item) => item.id === comment.id)) {
return prev;
@@ -588,6 +600,7 @@ export default function BoardDetailPage() {
setError(null);
setApprovalsError(null);
setChatError(null);
setGroupSnapshotError(null);
try {
const snapshotResult =
await getBoardSnapshotApiV1BoardsBoardIdSnapshotGet(boardId);
@@ -600,12 +613,38 @@ export default function BoardDetailPage() {
setAgents((snapshot.agents ?? []).map(normalizeAgent));
setApprovals((snapshot.approvals ?? []).map(normalizeApproval));
setChatMessages(snapshot.chat_messages ?? []);
try {
const groupResult =
await getBoardGroupSnapshotApiV1BoardsBoardIdGroupSnapshotGet(
boardId,
{
include_self: false,
include_done: false,
per_board_task_limit: 5,
},
);
if (groupResult.status === 200) {
setGroupSnapshot(groupResult.data);
} else {
setGroupSnapshot(null);
}
} catch (groupErr) {
const message =
groupErr instanceof Error
? groupErr.message
: "Unable to load board group snapshot.";
setGroupSnapshotError(message);
setGroupSnapshot(null);
}
} catch (err) {
const message =
err instanceof Error ? err.message : "Something went wrong.";
setError(message);
setApprovalsError(message);
setChatError(message);
setGroupSnapshotError(message);
setGroupSnapshot(null);
} finally {
setIsLoading(false);
setIsApprovalsLoading(false);
@@ -1381,9 +1420,7 @@ export default function BoardDetailPage() {
};
const postBoardChatMessage = useCallback(
async (
content: string,
): Promise<{ ok: boolean; error: string | null }> => {
async (content: string): Promise<{ ok: boolean; error: string | null }> => {
if (!isSignedIn || !boardId) {
return { ok: false, error: "Sign in to send messages." };
}
@@ -2163,10 +2200,15 @@ export default function BoardDetailPage() {
)
}
disabled={!isSignedIn || !boardId || isAgentsControlSending}
className={cn("h-9 w-9 p-0", isAgentsPaused
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
: "")}
aria-label={isAgentsPaused ? "Resume agents" : "Pause agents"}
className={cn(
"h-9 w-9 p-0",
isAgentsPaused
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
: "",
)}
aria-label={
isAgentsPaused ? "Resume agents" : "Pause agents"
}
title={isAgentsPaused ? "Resume agents" : "Pause agents"}
>
{isAgentsPaused ? (
@@ -2284,6 +2326,204 @@ export default function BoardDetailPage() {
</div>
) : (
<>
{viewMode === "list" ? (
<>
{groupSnapshotError ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 shadow-sm">
{groupSnapshotError}
</div>
) : null}
{groupSnapshot?.group ? (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Related boards
</p>
<p className="mt-1 truncate text-sm font-semibold text-slate-900">
{groupSnapshot.group.name}
</p>
{groupSnapshot.group.description ? (
<p className="mt-1 max-w-3xl text-xs text-slate-500 line-clamp-2">
{groupSnapshot.group.description}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
router.push(
`/board-groups/${groupSnapshot.group?.id}`,
)
}
disabled={!groupSnapshot.group?.id}
>
View group
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
router.push(`/boards/${boardId}/edit`)
}
disabled={!boardId}
>
Settings
</Button>
</div>
</div>
</div>
<div className="px-5 py-4">
{groupSnapshot.boards &&
groupSnapshot.boards.length ? (
<div className="grid gap-4 md:grid-cols-2">
{groupSnapshot.boards.map((item) => (
<div
key={item.board.id}
className="rounded-xl border border-slate-200 bg-slate-50/40 p-4"
>
<button
type="button"
className="group flex w-full items-start justify-between gap-3 text-left"
onClick={() =>
router.push(`/boards/${item.board.id}`)
}
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900 group-hover:text-blue-600">
{item.board.name}
</p>
<p className="mt-1 text-xs text-slate-500">
Updated{" "}
{formatTaskTimestamp(
item.board.updated_at,
)}
</p>
</div>
<ArrowUpRight className="mt-0.5 h-4 w-4 flex-shrink-0 text-slate-400 group-hover:text-blue-600" />
</button>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-700">
Inbox {item.task_counts?.inbox ?? 0}
</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-700">
In progress{" "}
{item.task_counts?.in_progress ?? 0}
</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-700">
Review {item.task_counts?.review ?? 0}
</span>
</div>
{item.tasks && item.tasks.length ? (
<ul className="mt-3 space-y-2">
{item.tasks.slice(0, 3).map((task) => (
<li
key={task.id}
className="rounded-lg border border-slate-200 bg-white p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
statusBadgeClass(
task.status,
),
)}
>
{task.status.replace(
/_/g,
" ",
)}
</span>
<span
className={cn(
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
priorityBadgeClass(
task.priority,
),
)}
>
{task.priority}
</span>
<p className="truncate text-sm font-medium text-slate-900">
{task.title}
</p>
</div>
<p className="text-xs text-slate-500">
{formatTaskTimestamp(
task.updated_at,
)}
</p>
</div>
<p className="mt-2 truncate text-xs text-slate-600">
Assignee:{" "}
<span className="font-medium text-slate-900">
{task.assignee ?? "Unassigned"}
</span>
</p>
</li>
))}
{item.tasks.length > 3 ? (
<li className="text-xs text-slate-500">
+{item.tasks.length - 3} more
</li>
) : null}
</ul>
) : (
<p className="mt-3 text-sm text-slate-500">
No tasks in this snapshot.
</p>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500">
No other boards in this group yet.
</p>
)}
</div>
</div>
) : groupSnapshot ? (
<div className="rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-600 shadow-sm">
<p className="font-semibold text-slate-900">
No board group configured
</p>
<p className="mt-1 text-sm text-slate-600">
Assign this board to a group to give agents
visibility into related work.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
router.push(`/boards/${boardId}/edit`)
}
disabled={!boardId}
>
Open settings
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/board-groups")}
>
View groups
</Button>
</div>
</div>
) : null}
</>
) : null}
{viewMode === "board" ? (
<TaskBoard
tasks={tasks}
@@ -3113,7 +3353,9 @@ export default function BoardDetailPage() {
<DialogContent aria-label="Agent controls">
<DialogHeader>
<DialogTitle>
{agentsControlAction === "pause" ? "Pause agents" : "Resume agents"}
{agentsControlAction === "pause"
? "Pause agents"
: "Resume agents"}
</DialogTitle>
<DialogDescription>
{agentsControlAction === "pause"
@@ -3150,7 +3392,10 @@ export default function BoardDetailPage() {
>
Cancel
</Button>
<Button onClick={handleConfirmAgentsControl} disabled={isAgentsControlSending}>
<Button
onClick={handleConfirmAgentsControl}
disabled={isAgentsControlSending}
>
{isAgentsControlSending
? "Sending…"
: agentsControlAction === "pause"

View File

@@ -10,10 +10,15 @@ import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import { useCreateBoardApiV1BoardsPost } from "@/api/generated/boards/boards";
import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { BoardGroupRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
@@ -33,6 +38,7 @@ export default function NewBoardPage() {
const [name, setName] = useState("");
const [gatewayId, setGatewayId] = useState<string>("");
const [boardGroupId, setBoardGroupId] = useState<string>("none");
const [error, setError] = useState<string | null>(null);
@@ -47,6 +53,17 @@ export default function NewBoardPage() {
},
});
const groupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
listBoardGroupsApiV1BoardGroupsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const createBoardMutation = useCreateBoardApiV1BoardsPost<ApiError>({
mutation: {
onSuccess: (result) => {
@@ -60,19 +77,36 @@ export default function NewBoardPage() {
},
});
const gateways =
gatewaysQuery.data?.status === 200
? gatewaysQuery.data.data.items ?? []
: [];
const gateways = useMemo(() => {
if (gatewaysQuery.data?.status !== 200) return [];
return gatewaysQuery.data.data.items ?? [];
}, [gatewaysQuery.data]);
const groups = useMemo<BoardGroupRead[]>(() => {
if (groupsQuery.data?.status !== 200) return [];
return groupsQuery.data.data.items ?? [];
}, [groupsQuery.data]);
const displayGatewayId = gatewayId || gateways[0]?.id || "";
const isLoading = gatewaysQuery.isLoading || createBoardMutation.isPending;
const errorMessage = error ?? gatewaysQuery.error?.message ?? null;
const isLoading =
gatewaysQuery.isLoading ||
groupsQuery.isLoading ||
createBoardMutation.isPending;
const errorMessage =
error ?? gatewaysQuery.error?.message ?? groupsQuery.error?.message ?? null;
const isFormReady = Boolean(name.trim() && displayGatewayId);
const gatewayOptions = useMemo(
() => gateways.map((gateway) => ({ value: gateway.id, label: gateway.name })),
[gateways]
() =>
gateways.map((gateway) => ({ value: gateway.id, label: gateway.name })),
[gateways],
);
const groupOptions = useMemo(
() => [
{ value: "none", label: "No group" },
...groups.map((group) => ({ value: group.id, label: group.name })),
],
[groups],
);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
@@ -96,6 +130,7 @@ export default function NewBoardPage() {
name: trimmedName,
slug: slugify(trimmedName),
gateway_id: resolvedGatewayId,
board_group_id: boardGroupId === "none" ? null : boardGroupId,
},
});
};
@@ -167,6 +202,29 @@ export default function NewBoardPage() {
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board group
</label>
<SearchableSelect
ariaLabel="Select board group"
value={boardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
searchPlaceholder="Search groups..."
emptyMessage="No groups 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"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Optional. Groups increase cross-board visibility.
</p>
</div>
</div>
</div>
{gateways.length === 0 ? (

View File

@@ -65,8 +65,10 @@ export default function BoardsPage() {
const boards = useMemo(
() =>
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [],
[boardsQuery.data]
boardsQuery.data?.status === 200
? (boardsQuery.data.data.items ?? [])
: [],
[boardsQuery.data],
);
const deleteMutation = useDeleteBoardApiV1BoardsBoardIdDelete<
@@ -78,20 +80,25 @@ export default function BoardsPage() {
onMutate: async ({ boardId }) => {
await queryClient.cancelQueries({ queryKey: boardsKey });
const previous =
queryClient.getQueryData<listBoardsApiV1BoardsGetResponse>(boardsKey);
queryClient.getQueryData<listBoardsApiV1BoardsGetResponse>(
boardsKey,
);
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(board) => board.id !== boardId
(board) => board.id !== boardId,
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listBoardsApiV1BoardsGetResponse>(boardsKey, {
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
queryClient.setQueryData<listBoardsApiV1BoardsGetResponse>(
boardsKey,
{
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
},
});
);
}
return { previous };
},
@@ -108,7 +115,7 @@ export default function BoardsPage() {
},
},
},
queryClient
queryClient,
);
const handleDelete = () => {
@@ -160,7 +167,7 @@ export default function BoardsPage() {
),
},
],
[]
[],
);
// eslint-disable-next-line react-hooks/incompatible-library
@@ -204,7 +211,10 @@ export default function BoardsPage() {
{boards.length > 0 ? (
<Link
href="/boards/new"
className={buttonVariants({ size: "md", variant: "primary" })}
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create board
</Link>
@@ -229,7 +239,7 @@ export default function BoardsPage() {
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
header.getContext(),
)}
</th>
))}
@@ -240,7 +250,9 @@ export default function BoardsPage() {
{boardsQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">Loading</span>
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
@@ -253,7 +265,7 @@ export default function BoardsPage() {
<td key={cell.id} className="px-6 py-4 align-top">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
cell.getContext(),
)}
</td>
))}
@@ -273,30 +285,10 @@ export default function BoardsPage() {
strokeLinecap="round"
strokeLinejoin="round"
>
<rect
x="3"
y="3"
width="7"
height="7"
/>
<rect
x="14"
y="3"
width="7"
height="7"
/>
<rect
x="14"
y="14"
width="7"
height="7"
/>
<rect
x="3"
y="14"
width="7"
height="7"
/>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
@@ -308,7 +300,10 @@ export default function BoardsPage() {
</p>
<Link
href="/boards/new"
className={buttonVariants({ size: "md", variant: "primary" })}
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first board
</Link>
@@ -342,7 +337,8 @@ export default function BoardsPage() {
<DialogHeader>
<DialogTitle>Delete board</DialogTitle>
<DialogDescription>
This will remove {deleteTarget?.name}. This action cannot be undone.
This will remove {deleteTarget?.name}. This action cannot be
undone.
</DialogDescription>
</DialogHeader>
{deleteMutation.error ? (

View File

@@ -58,31 +58,6 @@ type WipRangeSeries = {
points: WipPoint[];
};
type SeriesSet = {
primary: RangeSeries;
comparison: RangeSeries;
};
type WipSeriesSet = {
primary: WipRangeSeries;
comparison: WipRangeSeries;
};
type DashboardMetrics = {
range: RangeKey;
generated_at: string;
kpis: {
active_agents: number;
tasks_in_progress: number;
error_rate_pct: number;
median_cycle_time_hours_7d: number | null;
};
throughput: SeriesSet;
cycle_time: SeriesSet;
error_rate: SeriesSet;
wip: WipSeriesSet;
};
const hourFormatter = new Intl.DateTimeFormat("en-US", { hour: "numeric" });
const dayFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
@@ -96,7 +71,9 @@ const updatedFormatter = new Intl.DateTimeFormat("en-US", {
const formatPeriod = (value: string, bucket: BucketKey) => {
const date = parseApiDatetime(value);
if (!date) return "";
return bucket === "hour" ? hourFormatter.format(date) : dayFormatter.format(date);
return bucket === "hour"
? hourFormatter.format(date)
: dayFormatter.format(date);
};
const formatNumber = (value: number) => value.toLocaleString("en-US");
@@ -130,14 +107,18 @@ function buildWipSeries(series: WipRangeSeries) {
function buildSparkline(series: RangeSeries) {
return {
values: series.points.map((point) => Number(point.value ?? 0)),
labels: series.points.map((point) => formatPeriod(point.period, series.bucket)),
labels: series.points.map((point) =>
formatPeriod(point.period, series.bucket),
),
};
}
function buildWipSparkline(series: WipRangeSeries, key: keyof WipPoint) {
return {
values: series.points.map((point) => Number(point[key] ?? 0)),
labels: series.points.map((point) => formatPeriod(point.period, series.bucket)),
labels: series.points.map((point) =>
formatPeriod(point.period, series.bucket),
),
};
}
@@ -155,7 +136,10 @@ function TooltipCard({ active, payload, label, formatter }: TooltipProps) {
<div className="text-slate-400">{label}</div>
<div className="mt-1 space-y-1">
{payload.map((entry) => (
<div key={entry.name} className="flex items-center justify-between gap-3">
<div
key={entry.name}
className="flex items-center justify-between gap-3"
>
<span className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
@@ -164,7 +148,9 @@ function TooltipCard({ active, payload, label, formatter }: TooltipProps) {
{entry.name}
</span>
<span className="font-semibold text-slate-900">
{formatter ? formatter(Number(entry.value ?? 0), entry.name) : entry.value}
{formatter
? formatter(Number(entry.value ?? 0), entry.name)
: entry.value}
</span>
</div>
))}
@@ -192,12 +178,12 @@ function KpiCard({
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
{label}
</p>
<div className="rounded-lg bg-blue-50 p-2 text-blue-600">
{icon}
</div>
<div className="rounded-lg bg-blue-50 p-2 text-blue-600">{icon}</div>
</div>
<div className="flex items-end gap-2">
<h3 className="font-heading text-4xl font-bold text-slate-900">{value}</h3>
<h3 className="font-heading text-4xl font-bold text-slate-900">
{value}
</h3>
</div>
{sublabel ? (
<p className="mt-2 text-xs text-slate-500">{sublabel}</p>
@@ -304,7 +290,8 @@ export default function DashboardPage() {
[metrics],
);
const wipSpark = useMemo(
() => (metrics ? buildWipSparkline(metrics.wip.comparison, "in_progress") : null),
() =>
metrics ? buildWipSparkline(metrics.wip.comparison, "in_progress") : null,
[metrics],
);
@@ -312,10 +299,7 @@ export default function DashboardPage() {
() => (metrics ? Math.min(100, metrics.kpis.active_agents * 12.5) : 0),
[metrics],
);
const wipProgress = useMemo(
() => calcProgress(wipSpark?.values),
[wipSpark],
);
const wipProgress = useMemo(() => calcProgress(wipSpark?.values), [wipSpark]);
const errorProgress = useMemo(
() => calcProgress(errorSpark?.values),
[errorSpark],
@@ -372,7 +356,6 @@ export default function DashboardPage() {
</div>
</div>
<div className="p-8">
{metricsQuery.error ? (
<div className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-600 shadow-sm">
{metricsQuery.error.message}
@@ -425,33 +408,10 @@ export default function DashboardPage() {
sparkline={throughputSpark ?? undefined}
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={throughputSeries} margin={{ left: 4, right: 12 }}>
<CartesianGrid vertical={false} stroke="#e2e8f0" />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tick={{ fill: "#94a3b8", fontSize: 11 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{ fill: "#94a3b8", fontSize: 11 }}
width={40}
/>
<Tooltip content={<TooltipCard formatter={(v) => formatNumber(v)} />} />
<Bar dataKey="value" name="Completed" fill="#2563eb" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard
title="Avg Hours to Review"
subtitle="Cycle time"
sparkline={cycleSpark ?? undefined}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={cycleSeries} margin={{ left: 4, right: 12 }}>
<BarChart
data={throughputSeries}
margin={{ left: 4, right: 12 }}
>
<CartesianGrid vertical={false} stroke="#e2e8f0" />
<XAxis
dataKey="period"
@@ -466,7 +426,49 @@ export default function DashboardPage() {
width={40}
/>
<Tooltip
content={<TooltipCard formatter={(v) => `${v.toFixed(1)}h`} />}
content={
<TooltipCard formatter={(v) => formatNumber(v)} />
}
/>
<Bar
dataKey="value"
name="Completed"
fill="#2563eb"
radius={[6, 6, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard
title="Avg Hours to Review"
subtitle="Cycle time"
sparkline={cycleSpark ?? undefined}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={cycleSeries}
margin={{ left: 4, right: 12 }}
>
<CartesianGrid vertical={false} stroke="#e2e8f0" />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tick={{ fill: "#94a3b8", fontSize: 11 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{ fill: "#94a3b8", fontSize: 11 }}
width={40}
/>
<Tooltip
content={
<TooltipCard
formatter={(v) => `${v.toFixed(1)}h`}
/>
}
/>
<Line
type="monotone"
@@ -486,7 +488,10 @@ export default function DashboardPage() {
sparkline={errorSpark ?? undefined}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={errorSeries} margin={{ left: 4, right: 12 }}>
<LineChart
data={errorSeries}
margin={{ left: 4, right: 12 }}
>
<CartesianGrid vertical={false} stroke="#e2e8f0" />
<XAxis
dataKey="period"
@@ -501,7 +506,9 @@ export default function DashboardPage() {
width={40}
/>
<Tooltip
content={<TooltipCard formatter={(v) => formatPercent(v)} />}
content={
<TooltipCard formatter={(v) => formatPercent(v)} />
}
/>
<Line
type="monotone"
@@ -521,7 +528,10 @@ export default function DashboardPage() {
sparkline={wipSpark ?? undefined}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={wipSeries} margin={{ left: 4, right: 12 }}>
<AreaChart
data={wipSeries}
margin={{ left: 4, right: 12 }}
>
<CartesianGrid vertical={false} stroke="#e2e8f0" />
<XAxis
dataKey="period"
@@ -535,7 +545,11 @@ export default function DashboardPage() {
tick={{ fill: "#94a3b8", fontSize: 11 }}
width={40}
/>
<Tooltip content={<TooltipCard formatter={(v) => formatNumber(v)} />} />
<Tooltip
content={
<TooltipCard formatter={(v) => formatNumber(v)} />
}
/>
<Area
type="monotone"
dataKey="inbox"

View File

@@ -67,7 +67,7 @@ export default function EditGatewayPage() {
"idle" | "checking" | "success" | "error"
>("idle");
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
null
null,
);
const [error, setError] = useState<string | null>(null);
@@ -156,7 +156,7 @@ export default function EditGatewayPage() {
} catch (err) {
setGatewayCheckStatus("error");
setGatewayCheckMessage(
err instanceof Error ? err.message : "Unable to reach gateway."
err instanceof Error ? err.message : "Unable to reach gateway.",
);
}
};
@@ -343,7 +343,6 @@ export default function EditGatewayPage() {
</div>
</div>
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}

View File

@@ -92,7 +92,9 @@ export default function GatewayDetailPage() {
const agents = useMemo(
() =>
agentsQuery.data?.status === 200 ? agentsQuery.data.data.items ?? [] : [],
agentsQuery.data?.status === 200
? (agentsQuery.data.data.items ?? [])
: [],
[agentsQuery.data],
);
@@ -102,7 +104,7 @@ export default function GatewayDetailPage() {
const title = useMemo(
() => (gateway?.name ? gateway.name : "Gateway"),
[gateway?.name]
[gateway?.name],
);
return (
@@ -111,7 +113,10 @@ export default function GatewayDetailPage() {
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">Sign in to view a gateway.</p>
<SignInButton mode="modal" forceRedirectUrl={`/gateways/${gatewayId}`}>
<SignInButton
mode="modal"
forceRedirectUrl={`/gateways/${gatewayId}`}
>
<Button className="mt-4">Sign in</Button>
</SignInButton>
</div>
@@ -138,7 +143,9 @@ export default function GatewayDetailPage() {
Back to gateways
</Button>
{gatewayId ? (
<Button onClick={() => router.push(`/gateways/${gatewayId}/edit`)}>
<Button
onClick={() => router.push(`/gateways/${gatewayId}/edit`)}
>
Edit gateway
</Button>
) : null}
@@ -184,13 +191,17 @@ export default function GatewayDetailPage() {
</div>
<div className="mt-4 space-y-3 text-sm text-slate-700">
<div>
<p className="text-xs uppercase text-slate-400">Gateway URL</p>
<p className="text-xs uppercase text-slate-400">
Gateway URL
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{gateway.url}
</p>
</div>
<div>
<p className="text-xs uppercase text-slate-400">Token</p>
<p className="text-xs uppercase text-slate-400">
Token
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{maskToken(gateway.token)}
</p>
@@ -212,20 +223,26 @@ export default function GatewayDetailPage() {
</p>
</div>
<div>
<p className="text-xs uppercase text-slate-400">Workspace root</p>
<p className="text-xs uppercase text-slate-400">
Workspace root
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{gateway.workspace_root}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs uppercase text-slate-400">Created</p>
<p className="text-xs uppercase text-slate-400">
Created
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{formatTimestamp(gateway.created_at)}
</p>
</div>
<div>
<p className="text-xs uppercase text-slate-400">Updated</p>
<p className="text-xs uppercase text-slate-400">
Updated
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{formatTimestamp(gateway.updated_at)}
</p>

View File

@@ -46,7 +46,7 @@ export default function NewGatewayPage() {
const [gatewayUrl, setGatewayUrl] = useState("");
const [gatewayToken, setGatewayToken] = useState("");
const [mainSessionKey, setMainSessionKey] = useState(
DEFAULT_MAIN_SESSION_KEY
DEFAULT_MAIN_SESSION_KEY,
);
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
@@ -55,7 +55,7 @@ export default function NewGatewayPage() {
"idle" | "checking" | "success" | "error"
>("idle");
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
null
null,
);
const [error, setError] = useState<string | null>(null);
@@ -121,7 +121,7 @@ export default function NewGatewayPage() {
} catch (err) {
setGatewayCheckStatus("error");
setGatewayCheckMessage(
err instanceof Error ? err.message : "Unable to reach gateway."
err instanceof Error ? err.message : "Unable to reach gateway.",
);
}
};
@@ -167,7 +167,9 @@ export default function NewGatewayPage() {
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">Sign in to create a gateway.</p>
<p className="text-sm text-slate-600">
Sign in to create a gateway.
</p>
<SignInButton mode="modal" forceRedirectUrl="/gateways/new">
<Button className="mt-4">Sign in</Button>
</SignInButton>
@@ -302,7 +304,6 @@ export default function NewGatewayPage() {
</div>
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">

View File

@@ -78,9 +78,9 @@ export default function GatewaysPage() {
const gateways = useMemo(
() =>
gatewaysQuery.data?.status === 200
? gatewaysQuery.data.data.items ?? []
? (gatewaysQuery.data.data.items ?? [])
: [],
[gatewaysQuery.data]
[gatewaysQuery.data],
);
const sortedGateways = useMemo(() => [...gateways], [gateways]);
@@ -93,20 +93,25 @@ export default function GatewaysPage() {
onMutate: async ({ gatewayId }) => {
await queryClient.cancelQueries({ queryKey: gatewaysKey });
const previous =
queryClient.getQueryData<listGatewaysApiV1GatewaysGetResponse>(gatewaysKey);
queryClient.getQueryData<listGatewaysApiV1GatewaysGetResponse>(
gatewaysKey,
);
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(gateway) => gateway.id !== gatewayId
(gateway) => gateway.id !== gatewayId,
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listGatewaysApiV1GatewaysGetResponse>(gatewaysKey, {
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
queryClient.setQueryData<listGatewaysApiV1GatewaysGetResponse>(
gatewaysKey,
{
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
},
});
);
}
return { previous };
},
@@ -123,7 +128,7 @@ export default function GatewaysPage() {
},
},
},
queryClient
queryClient,
);
const handleDelete = () => {
@@ -137,10 +142,7 @@ export default function GatewaysPage() {
accessorKey: "name",
header: "Gateway",
cell: ({ row }) => (
<Link
href={`/gateways/${row.original.id}`}
className="group block"
>
<Link href={`/gateways/${row.original.id}`} className="group block">
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
{row.original.name}
</p>
@@ -181,25 +183,25 @@ export default function GatewaysPage() {
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex justify-end gap-2">
<Link
href={`/gateways/${row.original.id}/edit`}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
<div className="flex justify-end gap-2">
<Link
href={`/gateways/${row.original.id}/edit`}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
],
[]
[],
);
// eslint-disable-next-line react-hooks/incompatible-library
@@ -238,17 +240,20 @@ export default function GatewaysPage() {
Manage OpenClaw gateway connections used by boards
</p>
</div>
{gateways.length > 0 ? (
<Link
href="/gateways/new"
className={buttonVariants({ size: "md", variant: "primary" })}
>
Create gateway
</Link>
) : null}
{gateways.length > 0 ? (
<Link
href="/gateways/new"
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create gateway
</Link>
) : null}
</div>
</div>
</div>
</div>
<div className="p-8">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
@@ -263,7 +268,7 @@ export default function GatewaysPage() {
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
header.getContext(),
)}
</th>
))}
@@ -274,7 +279,9 @@ export default function GatewaysPage() {
{gatewaysQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">Loading</span>
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
@@ -284,7 +291,7 @@ export default function GatewaysPage() {
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
cell.getContext(),
)}
</td>
))}
@@ -324,7 +331,10 @@ export default function GatewaysPage() {
</p>
<Link
href="/gateways/new"
className={buttonVariants({ size: "md", variant: "primary" })}
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first gateway
</Link>
@@ -342,12 +352,14 @@ export default function GatewaysPage() {
{gatewaysQuery.error.message}
</p>
) : null}
</div>
</main>
</SignedIn>
<Dialog open={Boolean(deleteTarget)} onOpenChange={() => setDeleteTarget(null)}>
<Dialog
open={Boolean(deleteTarget)}
onOpenChange={() => setDeleteTarget(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete gateway?</DialogTitle>

View File

@@ -20,11 +20,9 @@
--warning: #d97706;
--danger: #dc2626;
--shadow-panel:
0 1px 3px rgba(15, 23, 42, 0.12),
0 1px 2px rgba(15, 23, 42, 0.08);
0 1px 3px rgba(15, 23, 42, 0.12), 0 1px 2px rgba(15, 23, 42, 0.08);
--shadow-card:
0 1px 2px rgba(15, 23, 42, 0.08),
0 2px 6px rgba(15, 23, 42, 0.06);
0 1px 2px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.06);
}
body {
@@ -148,7 +146,10 @@ body {
--warning: #f59e0b;
min-height: 100vh;
font-family: var(--font-body), -apple-system, sans-serif;
font-family:
var(--font-body),
-apple-system,
sans-serif;
background: var(--neutral-100);
color: var(--neutral-800);
line-height: 1.6;
@@ -220,7 +221,11 @@ body {
.landing-enterprise .logo-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--primary-navy), var(--secondary-navy));
background: linear-gradient(
135deg,
var(--primary-navy),
var(--secondary-navy)
);
border-radius: 8px;
display: flex;
align-items: center;
@@ -332,7 +337,11 @@ body {
.landing-enterprise .hero-label {
display: inline-block;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, rgba(10, 22, 40, 0.05), rgba(45, 212, 191, 0.08));
background: linear-gradient(
135deg,
rgba(10, 22, 40, 0.05),
rgba(45, 212, 191, 0.08)
);
border: 1px solid rgba(45, 212, 191, 0.2);
border-radius: 50px;
font-size: 13px;
@@ -455,7 +464,11 @@ body {
.landing-enterprise .surface-header {
padding: 1.5rem 2rem;
background: linear-gradient(135deg, var(--primary-navy), var(--secondary-navy));
background: linear-gradient(
135deg,
var(--primary-navy),
var(--secondary-navy)
);
color: white;
display: flex;
justify-content: space-between;
@@ -702,7 +715,11 @@ body {
.landing-enterprise .feature-number {
width: 48px;
height: 48px;
background: linear-gradient(135deg, rgba(10, 22, 40, 0.05), rgba(45, 212, 191, 0.08));
background: linear-gradient(
135deg,
rgba(10, 22, 40, 0.05),
rgba(45, 212, 191, 0.08)
);
border: 1px solid var(--neutral-200);
border-radius: 10px;
display: flex;
@@ -730,7 +747,11 @@ body {
.landing-enterprise .cta-section {
padding: 5rem 2.5rem;
background: linear-gradient(135deg, var(--primary-navy), var(--secondary-navy));
background: linear-gradient(
135deg,
var(--primary-navy),
var(--secondary-navy)
);
text-align: center;
}

View File

@@ -5,7 +5,13 @@ export const dynamic = "force-dynamic";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth, useUser } from "@/auth/clerk";
import {
SignInButton,
SignedIn,
SignedOut,
useAuth,
useUser,
} from "@/auth/clerk";
import { Globe, Info, RotateCcw, Save, User } from "lucide-react";
import { ApiError } from "@/api/mutator";
@@ -35,15 +41,16 @@ export default function OnboardingPage() {
const [timezone, setTimezone] = useState("");
const [error, setError] = useState<string | null>(null);
const meQuery = useGetMeApiV1UsersMeGet<getMeApiV1UsersMeGetResponse, ApiError>(
{
query: {
enabled: Boolean(isSignedIn),
retry: false,
refetchOnMount: "always",
},
const meQuery = useGetMeApiV1UsersMeGet<
getMeApiV1UsersMeGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
retry: false,
refetchOnMount: "always",
},
);
});
const updateMeMutation = useUpdateMeApiV1UsersMePatch<ApiError>({
mutation: {
@@ -63,12 +70,12 @@ export default function OnboardingPage() {
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 resolvedName = name.trim()
? name
: (profile?.preferred_name ?? profile?.name ?? clerkFallbackName ?? "");
const resolvedTimezone = timezone.trim()
? timezone
: (profile?.timezone ?? "");
const requiredMissing = useMemo(
() => [resolvedName, resolvedTimezone].some((value) => !value.trim()),
@@ -77,7 +84,9 @@ export default function OnboardingPage() {
const timezones = useMemo(() => {
if (typeof Intl !== "undefined" && "supportedValuesOf" in Intl) {
return (Intl as typeof Intl & { supportedValuesOf: (key: string) => string[] })
return (
Intl as typeof Intl & { supportedValuesOf: (key: string) => string[] }
)
.supportedValuesOf("timeZone")
.sort();
}