diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3178a9d..8dcd9c5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -55,6 +55,7 @@ jobs:
env:
# Keep CI builds deterministic and secretless.
NEXT_TELEMETRY_DISABLED: "1"
+ CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
run: |
make backend-lint
make backend-typecheck
diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py
index 780845b..a5a1633 100644
--- a/backend/app/api/activity.py
+++ b/backend/app/api/activity.py
@@ -103,16 +103,39 @@ def _coerce_task_comment_rows(
) -> list[tuple[ActivityEvent, Task, Board, Agent | None]]:
rows: list[tuple[ActivityEvent, Task, Board, Agent | None]] = []
for item in items:
+ first: Any
+ second: Any
+ third: Any
+ fourth: Any
+
+ if isinstance(item, tuple):
+ if len(item) != TASK_COMMENT_ROW_LEN:
+ msg = "Expected (ActivityEvent, Task, Board, Agent | None) rows"
+ raise TypeError(msg)
+ first, second, third, fourth = item
+ else:
+ try:
+ row_len = len(item)
+ first = item[0]
+ second = item[1]
+ third = item[2]
+ fourth = item[3]
+ except (IndexError, KeyError, TypeError):
+ msg = "Expected (ActivityEvent, Task, Board, Agent | None) rows"
+ raise TypeError(msg) from None
+ if row_len != TASK_COMMENT_ROW_LEN:
+ msg = "Expected (ActivityEvent, Task, Board, Agent | None) rows"
+ raise TypeError(msg)
+
if (
- isinstance(item, tuple)
- and len(item) == TASK_COMMENT_ROW_LEN
- and isinstance(item[0], ActivityEvent)
- and isinstance(item[1], Task)
- and isinstance(item[2], Board)
- and (isinstance(item[3], Agent) or item[3] is None)
+ isinstance(first, ActivityEvent)
+ and isinstance(second, Task)
+ and isinstance(third, Board)
+ and (isinstance(fourth, Agent) or fourth is None)
):
- rows.append((item[0], item[1], item[2], item[3]))
+ rows.append((first, second, third, fourth))
continue
+
msg = "Expected (ActivityEvent, Task, Board, Agent | None) rows"
raise TypeError(msg)
return rows
diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py
index 90c2853..1ad7b22 100644
--- a/backend/app/api/tasks.py
+++ b/backend/app/api/tasks.py
@@ -164,14 +164,32 @@ def _coerce_task_event_rows(
) -> list[tuple[ActivityEvent, Task | None]]:
rows: list[tuple[ActivityEvent, Task | None]] = []
for item in items:
- if (
- isinstance(item, tuple)
- and len(item) == TASK_EVENT_ROW_LEN
- and isinstance(item[0], ActivityEvent)
- and (isinstance(item[1], Task) or item[1] is None)
+ first: object
+ second: object
+
+ if isinstance(item, tuple):
+ if len(item) != TASK_EVENT_ROW_LEN:
+ msg = "Expected (ActivityEvent, Task | None) rows"
+ raise TypeError(msg)
+ first, second = item
+ else:
+ try:
+ row_len = len(item) # type: ignore[arg-type]
+ first = item[0] # type: ignore[index]
+ second = item[1] # type: ignore[index]
+ except (IndexError, KeyError, TypeError):
+ msg = "Expected (ActivityEvent, Task | None) rows"
+ raise TypeError(msg) from None
+ if row_len != TASK_EVENT_ROW_LEN:
+ msg = "Expected (ActivityEvent, Task | None) rows"
+ raise TypeError(msg)
+
+ if isinstance(first, ActivityEvent) and (
+ isinstance(second, Task) or second is None
):
- rows.append((item[0], item[1]))
+ rows.append((first, second))
continue
+
msg = "Expected (ActivityEvent, Task | None) rows"
raise TypeError(msg)
return rows
diff --git a/backend/tests/test_activity_api_rows.py b/backend/tests/test_activity_api_rows.py
new file mode 100644
index 0000000..a412e4d
--- /dev/null
+++ b/backend/tests/test_activity_api_rows.py
@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from uuid import uuid4
+
+import pytest
+
+from app.api.activity import _coerce_task_comment_rows
+from app.models.activity_events import ActivityEvent
+from app.models.agents import Agent
+from app.models.boards import Board
+from app.models.tasks import Task
+
+
+@dataclass
+class _FakeSqlRow4:
+ first: object
+ second: object
+ third: object
+ fourth: object
+
+ def __len__(self) -> int:
+ return 4
+
+ def __getitem__(self, index: int) -> object:
+ if index == 0:
+ return self.first
+ if index == 1:
+ return self.second
+ if index == 2:
+ return self.third
+ if index == 3:
+ return self.fourth
+ raise IndexError(index)
+
+
+def _make_event() -> ActivityEvent:
+ return ActivityEvent(event_type="task.comment", message="hello")
+
+
+def _make_board() -> Board:
+ return Board(
+ organization_id=uuid4(),
+ name="B",
+ slug="b",
+ )
+
+
+def _make_task(board_id) -> Task:
+ return Task(board_id=board_id, title="T")
+
+
+def _make_agent(board_id) -> Agent:
+ return Agent(
+ board_id=board_id,
+ gateway_id=uuid4(),
+ name="A",
+ )
+
+
+def test_coerce_task_comment_rows_accepts_plain_tuple():
+ board = _make_board()
+ task = _make_task(board.id)
+ event = _make_event()
+ agent = _make_agent(board.id)
+
+ rows = _coerce_task_comment_rows([(event, task, board, agent)])
+ assert rows == [(event, task, board, agent)]
+
+
+def test_coerce_task_comment_rows_accepts_row_like_values():
+ board = _make_board()
+ task = _make_task(board.id)
+ event = _make_event()
+ row = _FakeSqlRow4(event, task, board, None)
+
+ rows = _coerce_task_comment_rows([row])
+ assert rows == [(event, task, board, None)]
+
+
+def test_coerce_task_comment_rows_rejects_invalid_values():
+ board = _make_board()
+ task = _make_task(board.id)
+
+ with pytest.raises(
+ TypeError,
+ match="Expected \\(ActivityEvent, Task, Board, Agent \\| None\\) rows",
+ ):
+ _coerce_task_comment_rows([(uuid4(), task, board, None)])
diff --git a/backend/tests/test_tasks_api_rows.py b/backend/tests/test_tasks_api_rows.py
new file mode 100644
index 0000000..0c457b1
--- /dev/null
+++ b/backend/tests/test_tasks_api_rows.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from uuid import uuid4
+
+import pytest
+
+from app.api.tasks import _coerce_task_event_rows
+from app.models.activity_events import ActivityEvent
+from app.models.tasks import Task
+
+
+@dataclass
+class _FakeSqlRow:
+ first: object
+ second: object
+
+ def __len__(self) -> int:
+ return 2
+
+ def __getitem__(self, index: int) -> object:
+ if index == 0:
+ return self.first
+ if index == 1:
+ return self.second
+ raise IndexError(index)
+
+
+def _make_event() -> ActivityEvent:
+ return ActivityEvent(event_type="task.updated")
+
+
+def _make_task() -> Task:
+ return Task(board_id=uuid4(), title="T")
+
+
+def test_coerce_task_event_rows_accepts_plain_tuple():
+ event = _make_event()
+ task = _make_task()
+ rows = _coerce_task_event_rows([(event, task)])
+ assert rows == [(event, task)]
+
+
+def test_coerce_task_event_rows_accepts_row_like_values():
+ event = _make_event()
+ task = _make_task()
+ rows = _coerce_task_event_rows([_FakeSqlRow(event, task)])
+ assert rows == [(event, task)]
+
+
+def test_coerce_task_event_rows_rejects_invalid_values():
+ with pytest.raises(TypeError, match="Expected \\(ActivityEvent, Task \\| None\\) rows"):
+ _coerce_task_event_rows([("bad", "row")])
diff --git a/frontend/src/app/agents/[agentId]/page.tsx b/frontend/src/app/agents/[agentId]/page.tsx
index b5a153e..8e739a4 100644
--- a/frontend/src/app/agents/[agentId]/page.tsx
+++ b/frontend/src/app/agents/[agentId]/page.tsx
@@ -32,6 +32,7 @@ import type {
AgentRead,
BoardRead,
} from "@/api/generated/model";
+import { Markdown } from "@/components/atoms/Markdown";
import { StatusPill } from "@/components/atoms/StatusPill";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -338,9 +339,18 @@ export default function AgentDetailPage() {
key={event.id}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
>
-
- {event.message ?? event.event_type}
-
+ {event.message?.trim() ? (
+
+
+
+ ) : (
+
+ {event.event_type}
+
+ )}
{formatTimestamp(event.created_at)}
diff --git a/frontend/src/app/agents/page.tsx b/frontend/src/app/agents/page.tsx
index 47ff24c..fdffc91 100644
--- a/frontend/src/app/agents/page.tsx
+++ b/frontend/src/app/agents/page.tsx
@@ -3,28 +3,15 @@
export const dynamic = "force-dynamic";
import { useMemo, useState } from "react";
-import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuth } from "@/auth/clerk";
-import {
- type ColumnDef,
- type SortingState,
- flexRender,
- getCoreRowModel,
- getSortedRowModel,
- useReactTable,
-} from "@tanstack/react-table";
import { useQueryClient } from "@tanstack/react-query";
-import { StatusPill } from "@/components/atoms/StatusPill";
+import { AgentsTable } from "@/components/agents/AgentsTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
-import { Button, buttonVariants } from "@/components/ui/button";
+import { Button } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
-import {
- TableEmptyStateRow,
- TableLoadingRow,
-} from "@/components/ui/table-state";
import { ApiError } from "@/api/mutator";
import {
@@ -38,13 +25,19 @@ import {
getListBoardsApiV1BoardsGetQueryKey,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
-import {
- formatRelativeTimestamp as formatRelative,
- formatTimestamp,
- truncateText as truncate,
-} from "@/lib/formatters";
+import { type AgentRead } from "@/api/generated/model";
+import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
-import type { AgentRead } from "@/api/generated/model";
+import { useUrlSorting } from "@/lib/use-url-sorting";
+
+const AGENT_SORTABLE_COLUMNS = [
+ "name",
+ "status",
+ "openclaw_session_id",
+ "board_id",
+ "last_seen_at",
+ "updated_at",
+];
export default function AgentsPage() {
const { isSignedIn } = useAuth();
@@ -52,10 +45,11 @@ export default function AgentsPage() {
const router = useRouter();
const { isAdmin } = useOrganizationMembership(isSignedIn);
-
- const [sorting, setSorting] = useState([
- { id: "name", desc: false },
- ]);
+ const { sorting, onSortingChange } = useUrlSorting({
+ allowedColumnIds: AGENT_SORTABLE_COLUMNS,
+ defaultSorting: [{ id: "name", desc: false }],
+ paramPrefix: "agents",
+ });
const [deleteTarget, setDeleteTarget] = useState(null);
@@ -104,150 +98,29 @@ export default function AgentsPage() {
{ previous?: listAgentsApiV1AgentsGetResponse }
>(
{
- mutation: {
- onMutate: async ({ agentId }) => {
- await queryClient.cancelQueries({ queryKey: agentsKey });
- const previous =
- queryClient.getQueryData(
- agentsKey,
- );
- if (previous && previous.status === 200) {
- const nextItems = previous.data.items.filter(
- (agent) => agent.id !== agentId,
- );
- const removedCount = previous.data.items.length - nextItems.length;
- queryClient.setQueryData(
- agentsKey,
- {
- ...previous,
- data: {
- ...previous.data,
- items: nextItems,
- total: Math.max(0, previous.data.total - removedCount),
- },
- },
- );
- }
- return { previous };
- },
- onError: (_error, _agent, context) => {
- if (context?.previous) {
- queryClient.setQueryData(agentsKey, context.previous);
- }
- },
+ mutation: createOptimisticListDeleteMutation<
+ AgentRead,
+ listAgentsApiV1AgentsGetResponse,
+ { agentId: string }
+ >({
+ queryClient,
+ queryKey: agentsKey,
+ getItemId: (agent) => agent.id,
+ getDeleteId: ({ agentId }) => agentId,
onSuccess: () => {
setDeleteTarget(null);
},
- onSettled: () => {
- queryClient.invalidateQueries({ queryKey: agentsKey });
- queryClient.invalidateQueries({ queryKey: boardsKey });
- },
- },
+ invalidateQueryKeys: [agentsKey, boardsKey],
+ }),
},
queryClient,
);
- const sortedAgents = useMemo(() => [...agents], [agents]);
-
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate({ agentId: deleteTarget.id });
};
- const columns = useMemo[]>(() => {
- const resolveBoardName = (agent: AgentRead) =>
- boards.find((board) => board.id === agent.board_id)?.name ?? "—";
-
- return [
- {
- accessorKey: "name",
- header: "Agent",
- cell: ({ row }) => (
-
-
- {row.original.name}
-
- ID {row.original.id}
-
- ),
- },
- {
- accessorKey: "status",
- header: "Status",
- cell: ({ row }) => (
-
- ),
- },
- {
- accessorKey: "openclaw_session_id",
- header: "Session",
- cell: ({ row }) => (
-
- {truncate(row.original.openclaw_session_id)}
-
- ),
- },
- {
- accessorKey: "board_id",
- header: "Board",
- cell: ({ row }) => (
-
- {resolveBoardName(row.original)}
-
- ),
- },
- {
- accessorKey: "last_seen_at",
- header: "Last seen",
- cell: ({ row }) => (
-
- {formatRelative(row.original.last_seen_at)}
-
- ),
- },
- {
- accessorKey: "updated_at",
- header: "Updated",
- cell: ({ row }) => (
-
- {formatTimestamp(row.original.updated_at)}
-
- ),
- },
- {
- id: "actions",
- header: "",
- cell: ({ row }) => (
-
-
- Edit
-
-
-
- ),
- },
- ];
- }, [boards]);
-
- // eslint-disable-next-line react-hooks/incompatible-library
- const table = useReactTable({
- data: sortedAgents,
- columns,
- state: { sorting },
- onSortingChange: setSorting,
- getCoreRowModel: getCoreRowModel(),
- getSortedRowModel: getSortedRowModel(),
- });
-
return (
<>
-
-
-
- {table.getHeaderGroups().map((headerGroup) => (
-
- {headerGroup.headers.map((header) => (
- |
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext(),
- )}
- |
- ))}
-
- ))}
-
-
- {agentsQuery.isLoading ? (
-
- ) : table.getRowModel().rows.length ? (
- table.getRowModel().rows.map((row) => (
-
- {row.getVisibleCells().map((cell) => (
- |
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext(),
- )}
- |
- ))}
-
- ))
- ) : (
-
-
-
-
-
-
- }
- title="No agents yet"
- description="Create your first agent to start executing tasks on this board."
- actionHref="/agents/new"
- actionLabel="Create your first agent"
- />
- )}
-
-
-
+
{agentsQuery.error ? (
diff --git a/frontend/src/app/board-groups/page.tsx b/frontend/src/app/board-groups/page.tsx
index 6739c28..c05f893 100644
--- a/frontend/src/app/board-groups/page.tsx
+++ b/frontend/src/app/board-groups/page.tsx
@@ -6,12 +6,6 @@ import { useMemo, useState } from "react";
import Link from "next/link";
import { 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";
@@ -21,19 +15,24 @@ import {
useDeleteBoardGroupApiV1BoardGroupsGroupIdDelete,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
+import { BoardGroupsTable } from "@/components/board-groups/BoardGroupsTable";
import type { BoardGroupRead } from "@/api/generated/model";
+import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
+import { useUrlSorting } from "@/lib/use-url-sorting";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
-import { Button, buttonVariants } from "@/components/ui/button";
+import { buttonVariants } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
-import { formatTimestamp } from "@/lib/formatters";
-import {
- TableEmptyStateRow,
- TableLoadingRow,
-} from "@/components/ui/table-state";
+
+const BOARD_GROUP_SORTABLE_COLUMNS = ["name", "updated_at"];
export default function BoardGroupsPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
+ const { sorting, onSortingChange } = useUrlSorting({
+ allowedColumnIds: BOARD_GROUP_SORTABLE_COLUMNS,
+ defaultSorting: [{ id: "name", desc: false }],
+ paramPrefix: "board_groups",
+ });
const [deleteTarget, setDeleteTarget] = useState(null);
const groupsKey = getListBoardGroupsApiV1BoardGroupsGetQueryKey();
@@ -61,44 +60,20 @@ export default function BoardGroupsPage() {
{ previous?: listBoardGroupsApiV1BoardGroupsGetResponse }
>(
{
- mutation: {
- onMutate: async ({ groupId }) => {
- await queryClient.cancelQueries({ queryKey: groupsKey });
- const previous =
- queryClient.getQueryData(
- 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(
- 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);
- }
- },
+ mutation: createOptimisticListDeleteMutation<
+ BoardGroupRead,
+ listBoardGroupsApiV1BoardGroupsGetResponse,
+ { groupId: string }
+ >({
+ queryClient,
+ queryKey: groupsKey,
+ getItemId: (group) => group.id,
+ getDeleteId: ({ groupId }) => groupId,
onSuccess: () => {
setDeleteTarget(null);
},
- onSettled: () => {
- queryClient.invalidateQueries({ queryKey: groupsKey });
- },
- },
+ invalidateQueryKeys: [groupsKey],
+ }),
},
queryClient,
);
@@ -108,70 +83,6 @@ export default function BoardGroupsPage() {
deleteMutation.mutate({ groupId: deleteTarget.id });
};
- const columns = useMemo[]>(
- () => [
- {
- accessorKey: "name",
- header: "Group",
- cell: ({ row }) => (
-
-
- {row.original.name}
-
- {row.original.description ? (
-
- {row.original.description}
-
- ) : (
- No description
- )}
-
- ),
- },
- {
- accessorKey: "updated_at",
- header: "Updated",
- cell: ({ row }) => (
-
- {formatTimestamp(row.original.updated_at)}
-
- ),
- },
- {
- id: "actions",
- header: "",
- cell: ({ row }) => (
-
-
- Edit
-
-
-
- ),
- },
- ],
- [],
- );
-
- // eslint-disable-next-line react-hooks/incompatible-library
- const table = useReactTable({
- data: groups,
- columns,
- getCoreRowModel: getCoreRowModel(),
- });
-
return (
<>
-
-
-
- {table.getHeaderGroups().map((headerGroup) => (
-
- {headerGroup.headers.map((header) => (
- |
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext(),
- )}
- |
- ))}
-
- ))}
-
-
- {groupsQuery.isLoading ? (
-
- ) : table.getRowModel().rows.length ? (
- table.getRowModel().rows.map((row) => (
-
- {row.getVisibleCells().map((cell) => (
- |
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext(),
- )}
- |
- ))}
-
- ))
- ) : (
-
-
-
-
-
-
-
- }
- title="No groups yet"
- description="Create a board group to increase cross-board visibility for agents."
- actionHref="/board-groups/new"
- actionLabel="Create your first group"
- />
- )}
-
-
-
+
{groupsQuery.error ? (
diff --git a/frontend/src/app/boards/page.tsx b/frontend/src/app/boards/page.tsx
index d9e8d23..a01bba3 100644
--- a/frontend/src/app/boards/page.tsx
+++ b/frontend/src/app/boards/page.tsx
@@ -6,12 +6,6 @@ import { useMemo, useState } from "react";
import Link from "next/link";
import { 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";
@@ -25,23 +19,25 @@ import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
-import { formatTimestamp } from "@/lib/formatters";
+import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
-import type { BoardGroupRead, BoardRead } from "@/api/generated/model";
+import { useUrlSorting } from "@/lib/use-url-sorting";
+import type { BoardRead } from "@/api/generated/model";
+import { BoardsTable } from "@/components/boards/BoardsTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
-import { Button, buttonVariants } from "@/components/ui/button";
+import { buttonVariants } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
-import {
- TableEmptyStateRow,
- TableLoadingRow,
-} from "@/components/ui/table-state";
-const compactId = (value: string) =>
- value.length > 8 ? `${value.slice(0, 8)}…` : value;
+const BOARD_SORTABLE_COLUMNS = ["name", "group", "updated_at"];
export default function BoardsPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
+ const { sorting, onSortingChange } = useUrlSorting({
+ allowedColumnIds: BOARD_SORTABLE_COLUMNS,
+ defaultSorting: [{ id: "name", desc: false }],
+ paramPrefix: "boards",
+ });
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [deleteTarget, setDeleteTarget] = useState(null);
@@ -80,62 +76,30 @@ export default function BoardsPage() {
[boardsQuery.data],
);
- const groups = useMemo(() => {
+ const groups = useMemo(() => {
if (groupsQuery.data?.status !== 200) return [];
return groupsQuery.data.data.items ?? [];
}, [groupsQuery.data]);
- const groupById = useMemo(() => {
- const map = new Map();
- for (const group of groups) {
- map.set(group.id, group);
- }
- return map;
- }, [groups]);
-
const deleteMutation = useDeleteBoardApiV1BoardsBoardIdDelete<
ApiError,
{ previous?: listBoardsApiV1BoardsGetResponse }
>(
{
- mutation: {
- onMutate: async ({ boardId }) => {
- await queryClient.cancelQueries({ queryKey: boardsKey });
- const previous =
- queryClient.getQueryData(
- boardsKey,
- );
- if (previous && previous.status === 200) {
- const nextItems = previous.data.items.filter(
- (board) => board.id !== boardId,
- );
- const removedCount = previous.data.items.length - nextItems.length;
- queryClient.setQueryData(
- boardsKey,
- {
- ...previous,
- data: {
- ...previous.data,
- items: nextItems,
- total: Math.max(0, previous.data.total - removedCount),
- },
- },
- );
- }
- return { previous };
- },
- onError: (_error, _board, context) => {
- if (context?.previous) {
- queryClient.setQueryData(boardsKey, context.previous);
- }
- },
+ mutation: createOptimisticListDeleteMutation<
+ BoardRead,
+ listBoardsApiV1BoardsGetResponse,
+ { boardId: string }
+ >({
+ queryClient,
+ queryKey: boardsKey,
+ getItemId: (board) => board.id,
+ getDeleteId: ({ boardId }) => boardId,
onSuccess: () => {
setDeleteTarget(null);
},
- onSettled: () => {
- queryClient.invalidateQueries({ queryKey: boardsKey });
- },
- },
+ invalidateQueryKeys: [boardsKey],
+ }),
},
queryClient,
);
@@ -145,82 +109,6 @@ export default function BoardsPage() {
deleteMutation.mutate({ boardId: deleteTarget.id });
};
- const columns = useMemo[]>(
- () => [
- {
- accessorKey: "name",
- header: "Board",
- cell: ({ row }) => (
-
-
- {row.original.name}
-
-
- ),
- },
- {
- id: "group",
- header: "Group",
- cell: ({ row }) => {
- const groupId = row.original.board_group_id;
- if (!groupId) {
- return —;
- }
- const group = groupById.get(groupId);
- const label = group?.name ?? compactId(groupId);
- const title = group?.name ?? groupId;
- return (
-
- {label}
-
- );
- },
- },
- {
- accessorKey: "updated_at",
- header: "Updated",
- cell: ({ row }) => (
-
- {formatTimestamp(row.original.updated_at)}
-
- ),
- },
- {
- id: "actions",
- header: "",
- cell: ({ row }) => (
-
-
- Edit
-
-
-
- ),
- },
- ],
- [groupById],
- );
-
- // eslint-disable-next-line react-hooks/incompatible-library
- const table = useReactTable({
- data: boards,
- columns,
- getCoreRowModel: getCoreRowModel(),
- });
-
return (
<>
-
-
-
- {table.getHeaderGroups().map((headerGroup) => (
-
- {headerGroup.headers.map((header) => (
- |
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext(),
- )}
- |
- ))}
-
- ))}
-
-
- {boardsQuery.isLoading ? (
-
- ) : table.getRowModel().rows.length ? (
- table.getRowModel().rows.map((row) => (
-
- {row.getVisibleCells().map((cell) => (
- |
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext(),
- )}
- |
- ))}
-
- ))
- ) : (
-
-
-
-
-
-
- }
- title="No boards yet"
- description="Create your first board to start routing tasks and monitoring work across agents."
- actionHref="/boards/new"
- actionLabel="Create your first board"
- />
- )}
-
-
-
+
{boardsQuery.error ? (
diff --git a/frontend/src/app/gateways/[gatewayId]/page.tsx b/frontend/src/app/gateways/[gatewayId]/page.tsx
index dcc7701..b3f4a94 100644
--- a/frontend/src/app/gateways/[gatewayId]/page.tsx
+++ b/frontend/src/app/gateways/[gatewayId]/page.tsx
@@ -2,12 +2,21 @@
export const dynamic = "force-dynamic";
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/auth/clerk";
+import { useQueryClient } from "@tanstack/react-query";
+import { AgentsTable } from "@/components/agents/AgentsTable";
+import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
+import { Button } from "@/components/ui/button";
+import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { ApiError } from "@/api/mutator";
+import {
+ type listBoardsApiV1BoardsGetResponse,
+ useListBoardsApiV1BoardsGet,
+} from "@/api/generated/boards/boards";
import {
type gatewaysStatusApiV1GatewaysStatusGetResponse,
type getGatewayApiV1GatewaysGatewayIdGetResponse,
@@ -16,12 +25,14 @@ import {
} from "@/api/generated/gateways/gateways";
import {
type listAgentsApiV1AgentsGetResponse,
+ getListAgentsApiV1AgentsGetQueryKey,
+ useDeleteAgentApiV1AgentsAgentIdDelete,
useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents";
+import { type AgentRead } from "@/api/generated/model";
import { formatTimestamp } from "@/lib/formatters";
+import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
-import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
-import { Button } from "@/components/ui/button";
const maskToken = (value?: string | null) => {
if (!value) return "—";
@@ -31,6 +42,7 @@ const maskToken = (value?: string | null) => {
export default function GatewayDetailPage() {
const router = useRouter();
+ const queryClient = useQueryClient();
const params = useParams();
const { isSignedIn } = useAuth();
const gatewayIdParam = params?.gatewayId;
@@ -39,6 +51,10 @@ export default function GatewayDetailPage() {
: gatewayIdParam;
const { isAdmin } = useOrganizationMembership(isSignedIn);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const agentsKey = getListAgentsApiV1AgentsGetQueryKey(
+ gatewayId ? { gateway_id: gatewayId } : undefined,
+ );
const gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet<
getGatewayApiV1GatewaysGatewayIdGetResponse,
@@ -53,6 +69,16 @@ export default function GatewayDetailPage() {
const gateway =
gatewayQuery.data?.status === 200 ? gatewayQuery.data.data : null;
+ const boardsQuery = useListBoardsApiV1BoardsGet<
+ listBoardsApiV1BoardsGetResponse,
+ ApiError
+ >(undefined, {
+ query: {
+ enabled: Boolean(isSignedIn && isAdmin),
+ refetchInterval: 30_000,
+ },
+ });
+
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
@@ -62,6 +88,28 @@ export default function GatewayDetailPage() {
refetchInterval: 15_000,
},
});
+ const deleteMutation = useDeleteAgentApiV1AgentsAgentIdDelete<
+ ApiError,
+ { previous?: listAgentsApiV1AgentsGetResponse }
+ >(
+ {
+ mutation: createOptimisticListDeleteMutation<
+ AgentRead,
+ listAgentsApiV1AgentsGetResponse,
+ { agentId: string }
+ >({
+ queryClient,
+ queryKey: agentsKey,
+ getItemId: (agent) => agent.id,
+ getDeleteId: ({ agentId }) => agentId,
+ onSuccess: () => {
+ setDeleteTarget(null);
+ },
+ invalidateQueryKeys: [agentsKey],
+ }),
+ },
+ queryClient,
+ );
const statusParams = gateway
? {
@@ -87,6 +135,13 @@ export default function GatewayDetailPage() {
: [],
[agentsQuery.data],
);
+ const boards = useMemo(
+ () =>
+ boardsQuery.data?.status === 200
+ ? (boardsQuery.data.data.items ?? [])
+ : [],
+ [boardsQuery.data],
+ );
const status =
statusQuery.data?.status === 200 ? statusQuery.data.data : null;
@@ -96,174 +151,170 @@ export default function GatewayDetailPage() {
() => (gateway?.name ? gateway.name : "Gateway"),
[gateway?.name],
);
+ const handleDelete = () => {
+ if (!deleteTarget) return;
+ deleteMutation.mutate({ agentId: deleteTarget.id });
+ };
return (
-
-
- {isAdmin && gatewayId ? (
-