From 0ee469ee2f62000f18bbb4dd99a8558c2a91b152 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 21 May 2026 00:10:01 +0100 Subject: [PATCH 1/4] feat(dashboard): toggle assistant active/paused from list Convert the read-only StatusBadge on the assistants list into a Switch that calls assistants.update with the new status. Optimistic update patches all cached list queries; on error the snapshots are restored and the list is invalidated. Closes AGE-2471 --- .../src/pages/assistants/Assistants.tsx | 92 +++++++++++++++++-- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/client/dashboard/src/pages/assistants/Assistants.tsx b/client/dashboard/src/pages/assistants/Assistants.tsx index 95b41160bc..6304c34b63 100644 --- a/client/dashboard/src/pages/assistants/Assistants.tsx +++ b/client/dashboard/src/pages/assistants/Assistants.tsx @@ -3,33 +3,107 @@ import { Page } from "@/components/page-layout"; import { RequireScope } from "@/components/require-scope"; import { Card, Cards } from "@/components/ui/card"; import { MoreActions } from "@/components/ui/more-actions"; -import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; import { SimpleTooltip } from "@/components/ui/tooltip"; import { Type } from "@/components/ui/type"; import { UpdatedAt } from "@/components/updated-at"; import { useProductTier } from "@/hooks/useProductTier"; import { useRoutes } from "@/routes"; -import { Assistant } from "@gram/client/models/components/assistant.js"; +import { + Assistant, + AssistantStatus, +} from "@gram/client/models/components/assistant.js"; import { invalidateAllAssistantsList, useAssistantsDeleteMutation, useAssistantsList, + useAssistantsUpdateMutation, useGetPeriodUsage, } from "@gram/client/react-query/index.js"; import { Button, Icon, Stack } from "@speakeasy-api/moonshine"; -import { useQueryClient } from "@tanstack/react-query"; +import { QueryKey, useQueryClient } from "@tanstack/react-query"; import { Info, Plus } from "lucide-react"; +import { MouseEvent } from "react"; import { Outlet } from "react-router"; +import { toast } from "sonner"; export function AssistantsRoot() { return ; } -function StatusBadge({ status }: { status: string }) { - if (status === "active") return Active; - if (status === "paused") return Paused; - return {status}; +type AssistantsListData = { assistants: Assistant[] }; +type StatusToggleContext = { + snapshots: Array<[QueryKey, AssistantsListData | undefined]>; +}; + +function StatusToggle({ assistant }: { assistant: Assistant }) { + const queryClient = useQueryClient(); + const isActive = assistant.status === AssistantStatus.Active; + + const updateAssistant = useAssistantsUpdateMutation({ + onMutate: async ({ request }): Promise => { + const nextStatus = request.updateAssistantForm.status; + if (!nextStatus) return; + await queryClient.cancelQueries({ + queryKey: ["@gram/client", "assistants", "list"], + }); + const snapshots = queryClient.getQueriesData({ + queryKey: ["@gram/client", "assistants", "list"], + }); + for (const [key, data] of snapshots) { + if (!data) continue; + queryClient.setQueryData(key, { + ...data, + assistants: data.assistants.map((a) => + a.id === request.updateAssistantForm.id + ? { ...a, status: nextStatus } + : a, + ), + }); + } + return { snapshots }; + }, + onError: (_err, _vars, context) => { + const ctx = context as StatusToggleContext | undefined; + for (const [key, data] of ctx?.snapshots ?? []) { + queryClient.setQueryData(key, data); + } + invalidateAllAssistantsList(queryClient); + toast.error("Failed to update assistant status"); + }, + }); + + const handleToggle = () => { + updateAssistant.mutate({ + request: { + updateAssistantForm: { + id: assistant.id, + status: isActive ? AssistantStatus.Paused : AssistantStatus.Active, + }, + }, + }); + }; + + const stopLinkNavigation = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + return ( + +
+ +
+ + {isActive ? "Active" : "Paused"} + +
+ ); } function AssistantsEmptyState({ onCreate }: { onCreate: () => void }) { @@ -215,8 +289,8 @@ function AssistantCard({ assistant }: { assistant: Assistant }) { - - + + {assistant.model} From 59d811f430b46c3663f2d481066c02b497ef8225 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 21 May 2026 00:10:08 +0100 Subject: [PATCH 2/4] chore(changeset): assistant status toggle --- .changeset/age-2471-assistant-status-toggle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/age-2471-assistant-status-toggle.md diff --git a/.changeset/age-2471-assistant-status-toggle.md b/.changeset/age-2471-assistant-status-toggle.md new file mode 100644 index 0000000000..b21b6aea4b --- /dev/null +++ b/.changeset/age-2471-assistant-status-toggle.md @@ -0,0 +1,5 @@ +--- +"dashboard": patch +--- + +The active/paused indicator on each assistant card is now an interactive switch — you can pause or resume an assistant directly from the assistants list without opening it. From ec29dc7f915521c4bdbb1338c248b6fafd1196a7 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 21 May 2026 01:21:44 +0100 Subject: [PATCH 3/4] fix(dashboard): serialize assistant status toggle, hoist helper Drop optimistic cache patching in favour of disabling the switch while the mutation is in flight, then invalidating the list on success. This prevents out-of-order resolutions on rapid clicks (Codex P1). Hoist stopLinkNavigation to module scope. --- .../src/pages/assistants/Assistants.tsx | 48 ++++--------------- 1 file changed, 10 insertions(+), 38 deletions(-) diff --git a/client/dashboard/src/pages/assistants/Assistants.tsx b/client/dashboard/src/pages/assistants/Assistants.tsx index 6304c34b63..dfed405e25 100644 --- a/client/dashboard/src/pages/assistants/Assistants.tsx +++ b/client/dashboard/src/pages/assistants/Assistants.tsx @@ -22,54 +22,30 @@ import { useGetPeriodUsage, } from "@gram/client/react-query/index.js"; import { Button, Icon, Stack } from "@speakeasy-api/moonshine"; -import { QueryKey, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { Info, Plus } from "lucide-react"; import { MouseEvent } from "react"; import { Outlet } from "react-router"; import { toast } from "sonner"; +function stopLinkNavigation(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); +} + export function AssistantsRoot() { return ; } -type AssistantsListData = { assistants: Assistant[] }; -type StatusToggleContext = { - snapshots: Array<[QueryKey, AssistantsListData | undefined]>; -}; - function StatusToggle({ assistant }: { assistant: Assistant }) { const queryClient = useQueryClient(); const isActive = assistant.status === AssistantStatus.Active; const updateAssistant = useAssistantsUpdateMutation({ - onMutate: async ({ request }): Promise => { - const nextStatus = request.updateAssistantForm.status; - if (!nextStatus) return; - await queryClient.cancelQueries({ - queryKey: ["@gram/client", "assistants", "list"], - }); - const snapshots = queryClient.getQueriesData({ - queryKey: ["@gram/client", "assistants", "list"], - }); - for (const [key, data] of snapshots) { - if (!data) continue; - queryClient.setQueryData(key, { - ...data, - assistants: data.assistants.map((a) => - a.id === request.updateAssistantForm.id - ? { ...a, status: nextStatus } - : a, - ), - }); - } - return { snapshots }; - }, - onError: (_err, _vars, context) => { - const ctx = context as StatusToggleContext | undefined; - for (const [key, data] of ctx?.snapshots ?? []) { - queryClient.setQueryData(key, data); - } + onSuccess: () => { invalidateAllAssistantsList(queryClient); + }, + onError: () => { toast.error("Failed to update assistant status"); }, }); @@ -85,17 +61,13 @@ function StatusToggle({ assistant }: { assistant: Assistant }) { }); }; - const stopLinkNavigation = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - }; - return (
From 20587e251bd9df6ac691a4b2005f5ebe9f5589e6 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 21 May 2026 01:29:39 +0100 Subject: [PATCH 4/4] fix(dashboard): gate assistant status toggle on project:write --- client/dashboard/src/pages/assistants/Assistants.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/dashboard/src/pages/assistants/Assistants.tsx b/client/dashboard/src/pages/assistants/Assistants.tsx index dfed405e25..3eb2255ac9 100644 --- a/client/dashboard/src/pages/assistants/Assistants.tsx +++ b/client/dashboard/src/pages/assistants/Assistants.tsx @@ -9,6 +9,7 @@ import { SimpleTooltip } from "@/components/ui/tooltip"; import { Type } from "@/components/ui/type"; import { UpdatedAt } from "@/components/updated-at"; import { useProductTier } from "@/hooks/useProductTier"; +import { useRBAC } from "@/hooks/useRBAC"; import { useRoutes } from "@/routes"; import { Assistant, @@ -39,6 +40,8 @@ export function AssistantsRoot() { function StatusToggle({ assistant }: { assistant: Assistant }) { const queryClient = useQueryClient(); + const { hasScope } = useRBAC(); + const canWrite = hasScope("project:write"); const isActive = assistant.status === AssistantStatus.Active; const updateAssistant = useAssistantsUpdateMutation({ @@ -67,7 +70,7 @@ function StatusToggle({ assistant }: { assistant: Assistant }) {