diff --git a/apps/web/src/app/accounts/[id]/groups/page.tsx b/apps/web/src/app/accounts/[id]/groups/page.tsx
index 4661589..05e165f 100644
--- a/apps/web/src/app/accounts/[id]/groups/page.tsx
+++ b/apps/web/src/app/accounts/[id]/groups/page.tsx
@@ -4,7 +4,6 @@ import {
ArrowLeftIcon,
SearchIcon,
UsersIcon,
- RefreshCwIcon,
Users2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -16,6 +15,7 @@ import {
} from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator";
import { listGroupsForAccount } from "@/lib/queries";
+import { RefreshGroupsClient } from "./refresh-groups-client";
interface Props {
params: Promise<{ id: string }>;
@@ -57,13 +57,7 @@ export default async function GroupsListPage({ params, searchParams }: Props) {
- {/* Refresh button — no-op placeholder, wired in Task 17 */}
-
+
{/* Search */}
diff --git a/apps/web/src/app/accounts/[id]/groups/refresh-groups-client.tsx b/apps/web/src/app/accounts/[id]/groups/refresh-groups-client.tsx
new file mode 100644
index 0000000..0933063
--- /dev/null
+++ b/apps/web/src/app/accounts/[id]/groups/refresh-groups-client.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2Icon, RefreshCwIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useEvents } from "@/hooks/use-events";
+import { syncGroupsAction } from "@/actions/accounts";
+
+interface RefreshGroupsClientProps {
+ accountId: string;
+}
+
+/**
+ * Two-stage refresh button:
+ * 1. Click → server action pgNotifies the bot to start a sync.
+ * 2. Bot finishes → emits `groups.synced` over SSE → router.refresh()
+ * re-fetches the page so the new rows appear without the operator
+ * having to reload manually.
+ *
+ * The button stays in its "syncing" state until either the
+ * `groups.synced` event arrives for this account or 15 s pass (so a
+ * disconnected bot doesn't strand the spinner forever).
+ */
+export function RefreshGroupsClient({ accountId }: RefreshGroupsClientProps) {
+ const router = useRouter();
+ const [pending, start] = useTransition();
+ const [waiting, setWaiting] = useState(false);
+
+ useEvents({
+ "groups.synced": (data) => {
+ if (data.accountId !== accountId) return;
+ setWaiting(false);
+ router.refresh();
+ },
+ });
+
+ function trigger() {
+ start(async () => {
+ const fd = new FormData();
+ fd.append("accountId", accountId);
+ await syncGroupsAction(fd);
+ setWaiting(true);
+ // Belt-and-braces: if the bot is unreachable or the SSE channel
+ // drops, drop the spinner after 15 s instead of leaving it stuck.
+ window.setTimeout(() => setWaiting(false), 15_000);
+ });
+ }
+
+ const busy = pending || waiting;
+ return (
+
+ );
+}