From e9d768d9afddd6f2f6f91cad76532ae056a4fe7b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 1 Aug 2026 11:17:46 -0700 Subject: [PATCH 1/6] fix(dashboard): reuse cached API reads --- .../e2e/personal-tokens.spec.ts | 64 ++++++++++ packages/junior-dashboard/e2e/system.spec.ts | 5 + packages/junior-dashboard/src/client.tsx | 10 +- packages/junior-dashboard/src/client/App.tsx | 15 ++- packages/junior-dashboard/src/client/api.ts | 65 ++++++---- .../src/client/pages/PersonalTokensPage.tsx | 115 +++++++++++------- 6 files changed, 204 insertions(+), 70 deletions(-) create mode 100644 packages/junior-dashboard/e2e/personal-tokens.spec.ts diff --git a/packages/junior-dashboard/e2e/personal-tokens.spec.ts b/packages/junior-dashboard/e2e/personal-tokens.spec.ts new file mode 100644 index 000000000..7756b0e8b --- /dev/null +++ b/packages/junior-dashboard/e2e/personal-tokens.spec.ts @@ -0,0 +1,64 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + collectBrowserErrors, + type DashboardE2eServer, + mockDashboardApis, + startDashboardE2eServer, +} from "./harness"; + +let server: DashboardE2eServer; + +test.beforeAll(async () => { + server = await startDashboardE2eServer(); +}); + +test.afterAll(async () => { + await server.close(); +}); + +test.beforeEach(async ({ page }) => { + await mockDashboardApis(page); +}); + +test("reuses the personal token list across dashboard routes", async ({ + page, +}) => { + const browserErrors = collectBrowserErrors(page); + let listRequests = 0; + await page.route("**/api/personal-tokens", async (route) => { + listRequests += 1; + await route.fulfill({ + json: { + tokens: [ + { + createdAt: "2026-08-01T00:00:00.000Z", + expiresAt: "2026-10-30T00:00:00.000Z", + id: "00000000-0000-4000-8000-000000000001", + lastUsedAt: null, + name: "Local agent", + tokenSuffix: "abcd", + }, + ], + }, + }); + }); + + await page.goto(server.baseURL); + await openPersonalTokens(page); + await expect(page.getByText("Local agent", { exact: true })).toBeVisible(); + + await page.getByRole("link", { name: "System", exact: true }).click(); + await expect(page).toHaveURL(`${server.baseURL}/system`); + await openPersonalTokens(page); + + expect(listRequests).toBe(1); + expect(browserErrors).toEqual([]); +}); + +async function openPersonalTokens(page: Page) { + await page.getByRole("button", { name: /Open profile menu/ }).click(); + await page.getByRole("link", { name: "API tokens", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Personal API Tokens" }), + ).toBeVisible(); +} diff --git a/packages/junior-dashboard/e2e/system.spec.ts b/packages/junior-dashboard/e2e/system.spec.ts index f9e6db05c..40d91acab 100644 --- a/packages/junior-dashboard/e2e/system.spec.ts +++ b/packages/junior-dashboard/e2e/system.spec.ts @@ -23,9 +23,14 @@ test.beforeEach(async ({ page }) => { test("shows system usage and plugin details", async ({ page }) => { await page.setViewportSize({ height: 900, width: 1600 }); const browserErrors = collectBrowserErrors(page); + let identityRequests = 0; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/me") identityRequests += 1; + }); await page.goto(`${server.baseURL}/system`); await expect(page.getByText("Usage over time")).toBeVisible(); + expect(identityRequests).toBe(1); await expect(page.getByText("Model spend")).toBeVisible(); await expect(page.getByRole("region", { name: "Plugins" })).toHaveCount(0); diff --git a/packages/junior-dashboard/src/client.tsx b/packages/junior-dashboard/src/client.tsx index c9302939e..c1f3ecfd1 100644 --- a/packages/junior-dashboard/src/client.tsx +++ b/packages/junior-dashboard/src/client.tsx @@ -69,7 +69,15 @@ if (!root) { throw new Error("Junior dashboard root element was not found"); } -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Route changes should reuse recent dashboard reads. Resources that need + // faster updates own their polling or are invalidated by their mutation. + staleTime: 30_000, + }, + }, +}); createRoot(root).render( diff --git a/packages/junior-dashboard/src/client/App.tsx b/packages/junior-dashboard/src/client/App.tsx index b9cfb74a8..86a05cc08 100644 --- a/packages/junior-dashboard/src/client/App.tsx +++ b/packages/junior-dashboard/src/client/App.tsx @@ -34,6 +34,7 @@ import { dashboardContainerClass, dashboardInteractiveTextClass, } from "./styles"; +import type { DashboardCoreData } from "./types"; const dashboardBackground = { backgroundColor: "#050507", @@ -237,7 +238,15 @@ export function DashboardShell() { /> : + loading ? ( + + ) : data ? ( + + ) : ( + + ) } path="/system/*" /> @@ -292,8 +301,8 @@ export function DashboardShell() { ); } -function SystemRoute() { - const query = useSystemData(); +function SystemRoute(props: { coreData: DashboardCoreData }) { + const query = useSystemData(props.coreData); if (!query.data && !query.error) { return ; } diff --git a/packages/junior-dashboard/src/client/api.ts b/packages/junior-dashboard/src/client/api.ts index 3b6d23dbd..fa54a0a23 100644 --- a/packages/junior-dashboard/src/client/api.ts +++ b/packages/junior-dashboard/src/client/api.ts @@ -22,14 +22,16 @@ import { dashboardConfigSchema, dashboardIdentitySchema } from "../api/schema"; import { fetchDashboardJson } from "./http"; import type { DashboardCoreData, SystemData } from "./types"; +const dashboardMetadataStaleTimeMs = 5 * 60_000; + /** Fetch dashboard shell data shared across browser routes. */ export function useDashboardCoreData() { return useQuery({ queryKey: ["dashboard", "core"], - queryFn: async (): Promise => { + queryFn: async ({ signal }): Promise => { const [me, config] = await Promise.all([ - fetchDashboardJson(dashboardIdentitySchema, "/api/me"), - fetchDashboardJson(dashboardConfigSchema, "/api/config"), + fetchDashboardJson(dashboardIdentitySchema, "/api/me", signal), + fetchDashboardJson(dashboardConfigSchema, "/api/config", signal), ]); return { config, @@ -37,6 +39,7 @@ export function useDashboardCoreData() { }; }, retry: false, + staleTime: dashboardMetadataStaleTimeMs, }); } @@ -44,8 +47,10 @@ export function useDashboardCoreData() { export function usePluginsData() { return useQuery({ queryKey: ["dashboard", "plugins"], - queryFn: () => fetchDashboardJson(pluginsSchema, "/api/plugins"), + queryFn: ({ signal }) => + fetchDashboardJson(pluginsSchema, "/api/plugins", signal), retry: false, + staleTime: dashboardMetadataStaleTimeMs, }); } @@ -53,9 +58,10 @@ export function usePluginsData() { export function usePluginUserPagesData() { return useQuery({ queryKey: ["dashboard", "plugin-user-pages"], - queryFn: () => - fetchDashboardJson(pluginUserPageLinksSchema, "/api/user-pages"), + queryFn: ({ signal }) => + fetchDashboardJson(pluginUserPageLinksSchema, "/api/user-pages", signal), retry: false, + staleTime: dashboardMetadataStaleTimeMs, }); } @@ -66,11 +72,13 @@ export function useConversationsData(actorEmail?: string) { const search = query.toString(); return useQuery({ queryKey: ["dashboard", "conversations", actorEmail ?? "all"], - queryFn: () => + queryFn: ({ signal }) => fetchDashboardJson( conversationFeedSchema, `/api/conversations${search ? `?${search}` : ""}`, + signal, ), + refetchOnWindowFocus: "always", retry: false, }); } @@ -79,8 +87,8 @@ export function useConversationsData(actorEmail?: string) { export function useActorDirectoryData() { return useQuery({ queryKey: ["dashboard", "people"], - queryFn: () => - fetchDashboardJson(actorDirectoryReportSchema, "/api/people"), + queryFn: ({ signal }) => + fetchDashboardJson(actorDirectoryReportSchema, "/api/people", signal), retry: false, }); } @@ -90,10 +98,11 @@ export function useActorProfileData(email: string | undefined) { return useQuery({ enabled: Boolean(email), queryKey: ["dashboard", "people", email], - queryFn: async (): Promise => + queryFn: async ({ signal }): Promise => fetchDashboardJson( actorProfileReportSchema, `/api/people/${encodeURIComponent(email!)}`, + signal, ), retry: false, }); @@ -103,8 +112,12 @@ export function useActorProfileData(email: string | undefined) { export function useLocationDirectoryData() { return useQuery({ queryKey: ["dashboard", "locations"], - queryFn: () => - fetchDashboardJson(locationDirectoryReportSchema, "/api/locations"), + queryFn: ({ signal }) => + fetchDashboardJson( + locationDirectoryReportSchema, + "/api/locations", + signal, + ), retry: false, }); } @@ -114,48 +127,51 @@ export function useLocationDetailData(locationId: string | undefined) { return useQuery({ enabled: Boolean(locationId), queryKey: ["dashboard", "locations", locationId], - queryFn: async (): Promise => + queryFn: async ({ signal }): Promise => fetchDashboardJson( locationDetailReportSchema, `/api/locations/${encodeURIComponent(locationId!)}`, + signal, ), retry: false, }); } -/** Fetch aggregate system metrics, plugin inventory, and operational reports. */ -export function useSystemData() { - const coreQuery = useDashboardCoreData(); +/** Fetch system metrics, plugin inventory, and operational reports. */ +export function useSystemData(coreData: DashboardCoreData) { const pluginsQuery = usePluginsData(); const conversationStatsQuery = useQuery({ queryKey: ["dashboard", "conversation-stats"], - queryFn: () => + queryFn: ({ signal }) => fetchDashboardJson( conversationStatsReportSchema, "/api/conversations/stats", + signal, ), retry: false, }); const skillsQuery = useQuery({ queryKey: ["dashboard", "skills"], - queryFn: () => fetchDashboardJson(skillReportsSchema, "/api/skills"), + queryFn: ({ signal }) => + fetchDashboardJson(skillReportsSchema, "/api/skills", signal), retry: false, + staleTime: dashboardMetadataStaleTimeMs, }); const pluginReportsQuery = useQuery({ queryKey: ["dashboard", "plugin-reports"], - queryFn: () => + queryFn: ({ signal }) => fetchDashboardJson( pluginOperationalReportFeedSchema, "/api/plugin-reports", + signal, ), retry: false, }); - const dataReady = coreQuery.data && pluginsQuery.data && skillsQuery.data; + const dataReady = pluginsQuery.data && skillsQuery.data; return { - ...coreQuery, data: dataReady ? ({ - ...coreQuery.data, + ...coreData, conversationStatsError: Boolean(conversationStatsQuery.error), ...(conversationStatsQuery.data ? { conversationStats: conversationStatsQuery.data } @@ -170,8 +186,7 @@ export function useSystemData() { skills: skillsQuery.data, } satisfies SystemData) : undefined, - error: coreQuery.error ?? pluginsQuery.error ?? skillsQuery.error, - isPending: - coreQuery.isPending || pluginsQuery.isPending || skillsQuery.isPending, + error: pluginsQuery.error ?? skillsQuery.error, + isPending: pluginsQuery.isPending || skillsQuery.isPending, }; } diff --git a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx index fc8de2adf..ae41698ed 100644 --- a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx +++ b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx @@ -1,5 +1,6 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { KeyRound, Trash2 } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useRef, useState } from "react"; import { createdPersonalTokenSchema, personalTokenListSchema, @@ -10,60 +11,90 @@ import { dashboardContainerClass } from "../styles"; import { getDashboardAgentName } from "../agentName"; import { Button } from "../components/Button"; +const personalTokensQueryKey = ["dashboard", "personal-tokens"] as const; + /** Create and revoke personal API tokens for local clients. */ export function PersonalTokensPage() { - const [tokens, setTokens] = useState([]); + const queryClient = useQueryClient(); + const pendingCreatedToken = useRef<{ id: string; token: string } | undefined>( + undefined, + ); const [name, setName] = useState("Local agent"); const [createdToken, setCreatedToken] = useState<{ id: string; token: string; }>(); const [error, setError] = useState(); - const [loading, setLoading] = useState(true); - const [busy, setBusy] = useState(false); - - useEffect(() => { - void fetchDashboardJson(personalTokenListSchema, "/api/personal-tokens") - .then((result) => setTokens(result.tokens)) - .catch(() => setError("Could not load API tokens. Try again.")) - .finally(() => setLoading(false)); - }, []); - - async function createToken() { - setBusy(true); - setError(undefined); - try { - const token = await post( + const tokensQuery = useQuery({ + queryKey: personalTokensQueryKey, + queryFn: ({ signal }) => + fetchDashboardJson( + personalTokenListSchema, + "/api/personal-tokens", + signal, + ), + retry: false, + }); + const createTokenMutation = useMutation({ + mutationFn: async (tokenName: string) => { + const created = await post( createdPersonalTokenSchema, "/api/personal-tokens", - { name }, + { + name: tokenName, + }, ); - setTokens((current) => [token, ...current]); - setCreatedToken({ id: token.id, token: token.token }); - } catch { + const { token, ...metadata } = created; + // Mutation results remain cached after completion. Keep the one-time + // credential in component memory and return only safe list metadata. + pendingCreatedToken.current = { id: metadata.id, token }; + return metadata; + }, + onError: () => { + pendingCreatedToken.current = undefined; setError("Could not create the API token. Try again."); - } finally { - setBusy(false); - } - } - - async function revokeToken(token: PersonalTokenMetadata) { - setBusy(true); - setError(undefined); - try { - await deleteDashboardResource( + }, + onMutate: () => { + pendingCreatedToken.current = undefined; + setError(undefined); + }, + onSuccess: (metadata) => { + const created = pendingCreatedToken.current; + pendingCreatedToken.current = undefined; + queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>( + personalTokensQueryKey, + (current) => ({ tokens: [metadata, ...(current?.tokens ?? [])] }), + ); + if (created?.id === metadata.id) setCreatedToken(created); + }, + }); + const revokeTokenMutation = useMutation({ + mutationFn: (token: PersonalTokenMetadata) => + deleteDashboardResource( `/api/personal-tokens/${encodeURIComponent(token.id)}`, + ), + onError: () => setError("Could not revoke the API token. Try again."), + onMutate: () => setError(undefined), + onSuccess: (_result, token) => { + queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>( + personalTokensQueryKey, + (current) => ({ + tokens: (current?.tokens ?? []).filter( + (item) => item.id !== token.id, + ), + }), ); - setTokens((current) => current.filter((item) => item.id !== token.id)); setCreatedToken((current) => current?.id === token.id ? undefined : current, ); - } catch { - setError("Could not revoke the API token. Try again."); - } finally { - setBusy(false); - } - } + }, + }); + const tokens = tokensQuery.data?.tokens ?? []; + const loading = tokensQuery.isPending; + const busy = createTokenMutation.isPending || revokeTokenMutation.isPending; + const displayedError = tokensQuery.error + ? "Could not load API tokens. Try again." + : error; return (
@@ -104,14 +135,16 @@ export function PersonalTokensPage() { />
)} - {error ?

{error}

: null} + {displayedError ? ( +

{displayedError}

+ ) : null}
{loading ? ( @@ -142,7 +175,7 @@ export function PersonalTokensPage() { aria-label={`Revoke ${token.name}`} className="cursor-pointer border-0 bg-transparent p-1 text-dashboard-text-muted hover:text-rose-300" disabled={busy} - onClick={() => void revokeToken(token)} + onClick={() => revokeTokenMutation.mutate(token)} type="button" > From 13c1cd725163644c64f664263522d6e0bc380e4d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 1 Aug 2026 11:31:51 -0700 Subject: [PATCH 2/6] fix(dashboard): protect token mutation cache updates --- .../e2e/personal-tokens.spec.ts | 73 +++++++++++++++++++ .../src/client/pages/PersonalTokensPage.tsx | 19 ++++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/junior-dashboard/e2e/personal-tokens.spec.ts b/packages/junior-dashboard/e2e/personal-tokens.spec.ts index 7756b0e8b..21b485d2f 100644 --- a/packages/junior-dashboard/e2e/personal-tokens.spec.ts +++ b/packages/junior-dashboard/e2e/personal-tokens.spec.ts @@ -55,6 +55,71 @@ test("reuses the personal token list across dashboard routes", async ({ expect(browserErrors).toEqual([]); }); +test("keeps a created token when a stale list refetch is in flight", async ({ + page, +}) => { + const browserErrors = collectBrowserErrors(page); + const staleListStarted = promiseSignal(); + const releaseStaleList = promiseSignal(); + let listRequests = 0; + await page.route("**/api/personal-tokens", async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + json: { + createdAt: "2026-08-01T00:01:00.000Z", + expiresAt: "2026-10-30T00:01:00.000Z", + id: "00000000-0000-4000-8000-000000000002", + lastUsedAt: null, + name: "Review token", + token: "jr_pat_one-time-secret", + tokenSuffix: "wxyz", + }, + }); + return; + } + + listRequests += 1; + if (listRequests === 2) { + staleListStarted.resolve(); + await releaseStaleList.promise; + } + await route + .fulfill({ + json: { + tokens: [ + { + createdAt: "2026-08-01T00:00:00.000Z", + expiresAt: "2026-10-30T00:00:00.000Z", + id: "00000000-0000-4000-8000-000000000001", + lastUsedAt: null, + name: "Local agent", + tokenSuffix: "abcd", + }, + ], + }, + }) + .catch(() => undefined); + }); + + await page.goto(server.baseURL); + await openPersonalTokens(page); + await expect(page.getByText("Local agent", { exact: true })).toBeVisible(); + + await page.getByRole("link", { name: "System", exact: true }).click(); + await page.clock.setFixedTime(new Date(Date.now() + 31_000)); + await openPersonalTokens(page); + await staleListStarted.promise; + + await page.getByLabel("Token name").fill("Review token"); + await page.getByRole("button", { name: "Create token" }).click(); + await expect(page.getByText("jr_pat_one-time-secret")).toBeVisible(); + releaseStaleList.resolve(); + + await expect(page.getByText("Review token", { exact: true })).toBeVisible(); + expect(listRequests).toBe(2); + expect(browserErrors).toEqual([]); +}); + async function openPersonalTokens(page: Page) { await page.getByRole("button", { name: /Open profile menu/ }).click(); await page.getByRole("link", { name: "API tokens", exact: true }).click(); @@ -62,3 +127,11 @@ async function openPersonalTokens(page: Page) { page.getByRole("heading", { name: "Personal API Tokens" }), ).toBeVisible(); } + +function promiseSignal() { + let resolve!: () => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} diff --git a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx index ae41698ed..f26e3708c 100644 --- a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx +++ b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx @@ -25,6 +25,11 @@ export function PersonalTokensPage() { token: string; }>(); const [error, setError] = useState(); + const cancelTokenListRefetch = () => + queryClient.cancelQueries({ + exact: true, + queryKey: personalTokensQueryKey, + }); const tokensQuery = useQuery({ queryKey: personalTokensQueryKey, queryFn: ({ signal }) => @@ -54,13 +59,15 @@ export function PersonalTokensPage() { pendingCreatedToken.current = undefined; setError("Could not create the API token. Try again."); }, - onMutate: () => { + onMutate: async () => { pendingCreatedToken.current = undefined; setError(undefined); + await cancelTokenListRefetch(); }, - onSuccess: (metadata) => { + onSuccess: async (metadata) => { const created = pendingCreatedToken.current; pendingCreatedToken.current = undefined; + await cancelTokenListRefetch(); queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>( personalTokensQueryKey, (current) => ({ tokens: [metadata, ...(current?.tokens ?? [])] }), @@ -74,8 +81,12 @@ export function PersonalTokensPage() { `/api/personal-tokens/${encodeURIComponent(token.id)}`, ), onError: () => setError("Could not revoke the API token. Try again."), - onMutate: () => setError(undefined), - onSuccess: (_result, token) => { + onMutate: async () => { + setError(undefined); + await cancelTokenListRefetch(); + }, + onSuccess: async (_result, token) => { + await cancelTokenListRefetch(); queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>( personalTokensQueryKey, (current) => ({ From 07120ce46b92cfae58e5200e02f7150e9c252033 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 1 Aug 2026 11:43:59 -0700 Subject: [PATCH 3/6] fix(dashboard): preserve token query errors --- .../e2e/personal-tokens.spec.ts | 54 +++++++++++++++++++ .../src/client/pages/PersonalTokensPage.tsx | 17 ++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/junior-dashboard/e2e/personal-tokens.spec.ts b/packages/junior-dashboard/e2e/personal-tokens.spec.ts index 21b485d2f..ce4f99fbd 100644 --- a/packages/junior-dashboard/e2e/personal-tokens.spec.ts +++ b/packages/junior-dashboard/e2e/personal-tokens.spec.ts @@ -120,6 +120,60 @@ test("keeps a created token when a stale list refetch is in flight", async ({ expect(browserErrors).toEqual([]); }); +test("keeps cached tokens and mutation errors after a refetch fails", async ({ + page, +}) => { + const backgroundRefetchFinished = promiseSignal(); + let listRequests = 0; + await page.route("**/api/personal-tokens", async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ status: 500 }); + return; + } + + listRequests += 1; + if (listRequests === 2) { + await route.fulfill({ status: 500 }); + backgroundRefetchFinished.resolve(); + return; + } + await route.fulfill({ + json: { + tokens: [ + { + createdAt: "2026-08-01T00:00:00.000Z", + expiresAt: "2026-10-30T00:00:00.000Z", + id: "00000000-0000-4000-8000-000000000001", + lastUsedAt: null, + name: "Local agent", + tokenSuffix: "abcd", + }, + ], + }, + }); + }); + + await page.goto(server.baseURL); + await openPersonalTokens(page); + await expect(page.getByText("Local agent", { exact: true })).toBeVisible(); + + await page.getByRole("link", { name: "System", exact: true }).click(); + await page.clock.setFixedTime(new Date(Date.now() + 31_000)); + await openPersonalTokens(page); + await backgroundRefetchFinished.promise; + await expect(page.getByText("Local agent", { exact: true })).toBeVisible(); + + await page.getByLabel("Token name").fill("Review token"); + await page.getByRole("button", { name: "Create token" }).click(); + await expect( + page.getByText("Could not create the API token. Try again."), + ).toBeVisible(); + await expect( + page.getByText("Could not load API tokens. Try again."), + ).toHaveCount(0); + expect(listRequests).toBe(2); +}); + async function openPersonalTokens(page: Page) { await page.getByRole("button", { name: /Open profile menu/ }).click(); await page.getByRole("link", { name: "API tokens", exact: true }).click(); diff --git a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx index f26e3708c..5baa91ac6 100644 --- a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx +++ b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx @@ -70,7 +70,14 @@ export function PersonalTokensPage() { await cancelTokenListRefetch(); queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>( personalTokensQueryKey, - (current) => ({ tokens: [metadata, ...(current?.tokens ?? [])] }), + (current) => ({ + tokens: [ + metadata, + ...(current?.tokens ?? []).filter( + (token) => token.id !== metadata.id, + ), + ], + }), ); if (created?.id === metadata.id) setCreatedToken(created); }, @@ -103,9 +110,11 @@ export function PersonalTokensPage() { const tokens = tokensQuery.data?.tokens ?? []; const loading = tokensQuery.isPending; const busy = createTokenMutation.isPending || revokeTokenMutation.isPending; - const displayedError = tokensQuery.error - ? "Could not load API tokens. Try again." - : error; + const displayedError = + error ?? + (!tokensQuery.data && tokensQuery.error + ? "Could not load API tokens. Try again." + : undefined); return (
From 19799ed0ecc8829d8d2f25e9196fa5382560a7c8 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 1 Aug 2026 11:54:21 -0700 Subject: [PATCH 4/6] fix(dashboard): prevent duplicate token creation --- .../e2e/personal-tokens.spec.ts | 42 +++++++++++++++++++ .../src/client/pages/PersonalTokensPage.tsx | 10 ++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/junior-dashboard/e2e/personal-tokens.spec.ts b/packages/junior-dashboard/e2e/personal-tokens.spec.ts index ce4f99fbd..7db1b411c 100644 --- a/packages/junior-dashboard/e2e/personal-tokens.spec.ts +++ b/packages/junior-dashboard/e2e/personal-tokens.spec.ts @@ -120,6 +120,48 @@ test("keeps a created token when a stale list refetch is in flight", async ({ expect(browserErrors).toEqual([]); }); +test("starts only one token create for rapid clicks", async ({ page }) => { + const browserErrors = collectBrowserErrors(page); + const releaseCreate = promiseSignal(); + let createRequests = 0; + await page.route("**/api/personal-tokens", async (route) => { + if (route.request().method() === "POST") { + createRequests += 1; + await releaseCreate.promise; + await route.fulfill({ + json: { + createdAt: "2026-08-01T00:01:00.000Z", + expiresAt: "2026-10-30T00:01:00.000Z", + id: "00000000-0000-4000-8000-000000000002", + lastUsedAt: null, + name: "Review token", + token: "jr_pat_one-time-secret", + tokenSuffix: "wxyz", + }, + }); + return; + } + + await route.fulfill({ json: { tokens: [] } }); + }); + + await page.goto(server.baseURL); + await openPersonalTokens(page); + await page.getByLabel("Token name").fill("Review token"); + await page + .getByRole("button", { name: "Create token" }) + .evaluate((button) => { + button.click(); + button.click(); + }); + await expect.poll(() => createRequests).toBe(1); + releaseCreate.resolve(); + + await expect(page.getByText("jr_pat_one-time-secret")).toBeVisible(); + expect(createRequests).toBe(1); + expect(browserErrors).toEqual([]); +}); + test("keeps cached tokens and mutation errors after a refetch fails", async ({ page, }) => { diff --git a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx index 5baa91ac6..3f213b56b 100644 --- a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx +++ b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx @@ -16,6 +16,7 @@ const personalTokensQueryKey = ["dashboard", "personal-tokens"] as const; /** Create and revoke personal API tokens for local clients. */ export function PersonalTokensPage() { const queryClient = useQueryClient(); + const createTokenStarted = useRef(false); const pendingCreatedToken = useRef<{ id: string; token: string } | undefined>( undefined, ); @@ -59,6 +60,9 @@ export function PersonalTokensPage() { pendingCreatedToken.current = undefined; setError("Could not create the API token. Try again."); }, + onSettled: () => { + createTokenStarted.current = false; + }, onMutate: async () => { pendingCreatedToken.current = undefined; setError(undefined); @@ -155,7 +159,11 @@ export function PersonalTokensPage() { /> From aa3d635e61792503dd64d265a3992b90fa424018 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 1 Aug 2026 12:06:35 -0700 Subject: [PATCH 5/6] refactor(dashboard): use token mutation state --- .../src/client/pages/PersonalTokensPage.tsx | 78 +++++++------------ 1 file changed, 29 insertions(+), 49 deletions(-) diff --git a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx index 3f213b56b..030c6f8b3 100644 --- a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx +++ b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx @@ -17,15 +17,7 @@ const personalTokensQueryKey = ["dashboard", "personal-tokens"] as const; export function PersonalTokensPage() { const queryClient = useQueryClient(); const createTokenStarted = useRef(false); - const pendingCreatedToken = useRef<{ id: string; token: string } | undefined>( - undefined, - ); const [name, setName] = useState("Local agent"); - const [createdToken, setCreatedToken] = useState<{ - id: string; - token: string; - }>(); - const [error, setError] = useState(); const cancelTokenListRefetch = () => queryClient.cancelQueries({ exact: true, @@ -42,35 +34,14 @@ export function PersonalTokensPage() { retry: false, }); const createTokenMutation = useMutation({ - mutationFn: async (tokenName: string) => { - const created = await post( - createdPersonalTokenSchema, - "/api/personal-tokens", - { - name: tokenName, - }, - ); + gcTime: 0, + mutationFn: (tokenName: string) => + post(createdPersonalTokenSchema, "/api/personal-tokens", { + name: tokenName, + }), + onMutate: () => cancelTokenListRefetch(), + onSuccess: async (created) => { const { token, ...metadata } = created; - // Mutation results remain cached after completion. Keep the one-time - // credential in component memory and return only safe list metadata. - pendingCreatedToken.current = { id: metadata.id, token }; - return metadata; - }, - onError: () => { - pendingCreatedToken.current = undefined; - setError("Could not create the API token. Try again."); - }, - onSettled: () => { - createTokenStarted.current = false; - }, - onMutate: async () => { - pendingCreatedToken.current = undefined; - setError(undefined); - await cancelTokenListRefetch(); - }, - onSuccess: async (metadata) => { - const created = pendingCreatedToken.current; - pendingCreatedToken.current = undefined; await cancelTokenListRefetch(); queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>( personalTokensQueryKey, @@ -83,7 +54,9 @@ export function PersonalTokensPage() { ], }), ); - if (created?.id === metadata.id) setCreatedToken(created); + }, + onSettled: () => { + createTokenStarted.current = false; }, }); const revokeTokenMutation = useMutation({ @@ -91,11 +64,7 @@ export function PersonalTokensPage() { deleteDashboardResource( `/api/personal-tokens/${encodeURIComponent(token.id)}`, ), - onError: () => setError("Could not revoke the API token. Try again."), - onMutate: async () => { - setError(undefined); - await cancelTokenListRefetch(); - }, + onMutate: () => cancelTokenListRefetch(), onSuccess: async (_result, token) => { await cancelTokenListRefetch(); queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>( @@ -106,17 +75,22 @@ export function PersonalTokensPage() { ), }), ); - setCreatedToken((current) => - current?.id === token.id ? undefined : current, - ); + if (createTokenMutation.data?.id === token.id) { + createTokenMutation.reset(); + } }, }); + const createdToken = createTokenMutation.data; const tokens = tokensQuery.data?.tokens ?? []; const loading = tokensQuery.isPending; const busy = createTokenMutation.isPending || revokeTokenMutation.isPending; const displayedError = - error ?? - (!tokensQuery.data && tokensQuery.error + (createTokenMutation.isError + ? "Could not create the API token. Try again." + : revokeTokenMutation.isError + ? "Could not revoke the API token. Try again." + : undefined) ?? + (!tokensQuery.data && tokensQuery.isError ? "Could not load API tokens. Try again." : undefined); @@ -141,7 +115,7 @@ export function PersonalTokensPage() {
@@ -458,12 +468,9 @@ function OverviewBreakdownRow(props: { } function MemoryRow(props: { - action: UseMutationResult< - boolean, - Error, - NonNullable[number] - >; + action: MemoryActionMutation; first: boolean; + onAction(action: PluginUserPageRecordAction): void; onSelect(): void; record: PluginUserPageRecord; selected: boolean; @@ -573,7 +580,12 @@ function MemoryRow(props: {
{props.selected ? (
- +
) : null} @@ -581,12 +593,9 @@ function MemoryRow(props: { } function MemoryMobileInspector(props: { - action: UseMutationResult< - boolean, - Error, - NonNullable[number] - >; + action: MemoryActionMutation; onClose(): void; + onAction(action: PluginUserPageRecordAction): void; record: PluginUserPageRecord | undefined; }) { useEffect(() => { @@ -633,6 +642,7 @@ function MemoryMobileInspector(props: { @@ -641,13 +651,10 @@ function MemoryMobileInspector(props: { } function MemoryDetails(props: { - action: UseMutationResult< - boolean, - Error, - NonNullable[number] - >; + action: MemoryActionMutation; inline?: boolean; onClose?: () => void; + onAction(action: PluginUserPageRecordAction): void; record: PluginUserPageRecord; }) { const kind = metadataValue(props.record, "Type"); @@ -781,11 +788,8 @@ function MemoryDetails(props: { {forgetAction ? (