diff --git a/package.json b/package.json index 7391a1723..11737157e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "worktree:setup": "node scripts/worktree.mjs setup", "cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs", "prepare": "simple-git-hooks", - "lint": "pnpm file-length:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-vercel lint && pnpm ast-grep:lint && pnpm package:lint", + "lint": "pnpm file-length:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint", "lint:fix": "pnpm --filter @sentry/junior lint:fix", "file-length:check": "node --test scripts/check-file-length.test.mjs && node scripts/check-file-length.mjs", "test-architecture:check": "node --test scripts/check-test-architecture.test.mjs && node scripts/check-test-architecture.mjs", @@ -40,6 +40,7 @@ "pre-commit": "pnpm lint-staged" }, "lint-staged": { + "packages/junior-dashboard/src/**/*.{ts,tsx}": "pnpm --filter @sentry/junior-dashboard lint", "packages/junior/**/*.{js,jsx,ts,tsx,mjs,cjs}": "pnpm --filter @sentry/junior exec oxlint --config .oxlintrc.json --deny-warnings --fix", "*.{js,jsx,ts,tsx,mjs,cjs,json,md,mdx,yml,yaml}": [ "pnpm file-length:check", diff --git a/packages/junior-dashboard/.oxlintrc.json b/packages/junior-dashboard/.oxlintrc.json new file mode 100644 index 000000000..15ad205b1 --- /dev/null +++ b/packages/junior-dashboard/.oxlintrc.json @@ -0,0 +1,13 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "jsPlugins": ["@tanstack/eslint-plugin-query"], + "rules": { + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/infinite-query-property-order": "error", + "@tanstack/query/mutation-property-order": "error", + "@tanstack/query/no-rest-destructuring": "error", + "@tanstack/query/no-unstable-deps": "error", + "@tanstack/query/no-void-query-fn": "error", + "@tanstack/query/stable-query-client": "error" + } +} diff --git a/packages/junior-dashboard/e2e/conversations.spec.ts b/packages/junior-dashboard/e2e/conversations.spec.ts index 8dd1ffae7..363b8d95e 100644 --- a/packages/junior-dashboard/e2e/conversations.spec.ts +++ b/packages/junior-dashboard/e2e/conversations.spec.ts @@ -21,6 +21,29 @@ test.beforeEach(async ({ page }) => { await mockDashboardApis(page); }); +test("reuses the fresh conversation feed after window focus", async ({ + page, +}) => { + let requests = 0; + await page.route("**/api/conversations?*", async (route) => { + requests += 1; + await route.fallback(); + }); + + await page.goto(server.baseURL); + await expect( + page.getByRole("heading", { name: "Conversations" }), + ).toBeVisible(); + expect(requests).toBe(1); + + await page.evaluate(() => { + window.dispatchEvent(new Event("visibilitychange")); + }); + await page.waitForTimeout(100); + + expect(requests).toBe(1); +}); + test("opens a conversation in the built dashboard", async ({ page }) => { await page.setViewportSize({ height: 900, width: 1600 }); const browserErrors = collectBrowserErrors(page); @@ -362,6 +385,7 @@ test("inspects and copies an advisor transcript", async ({ context, page }) => { test("archives and restores a conversation from the sidebar", async ({ page, }) => { + const initialTime = Date.now(); await page.setViewportSize({ height: 900, width: 1600 }); let archived = false; await page.route(/\/api\/conversations(?:\?.*)?$/, async (route) => { @@ -414,6 +438,7 @@ test("archives and restores a conversation from the sidebar", async ({ response.request().method() === "GET" && /\/api\/conversations(?:\?.*)?$/.test(response.url()), ); + await page.clock.setFixedTime(new Date(initialTime + 31_000)); await page.evaluate(() => { window.dispatchEvent(new Event("focus")); window.dispatchEvent(new Event("visibilitychange")); @@ -447,6 +472,7 @@ test("archives and restores a conversation from the sidebar", async ({ response.request().method() === "GET" && /\/api\/conversations(?:\?.*)?$/.test(response.url()), ); + await page.clock.setFixedTime(new Date(initialTime + 62_000)); await page.evaluate(() => { window.dispatchEvent(new Event("focus")); window.dispatchEvent(new Event("visibilitychange")); 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..7db1b411c --- /dev/null +++ b/packages/junior-dashboard/e2e/personal-tokens.spec.ts @@ -0,0 +1,233 @@ +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([]); +}); + +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([]); +}); + +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, +}) => { + 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(); + await expect( + 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/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/e2e/user-pages.spec.ts b/packages/junior-dashboard/e2e/user-pages.spec.ts index 0af53af7c..c5a647dc0 100644 --- a/packages/junior-dashboard/e2e/user-pages.spec.ts +++ b/packages/junior-dashboard/e2e/user-pages.spec.ts @@ -183,6 +183,7 @@ test("searches, paginates, and forgets plugin page records", async ({ page, }) => { let forgotMemory = false; + let forgetRequests = 0; let dashboardRequestCount = 0; await page.route("**/api/plugins/memory/dashboard", async (route) => { dashboardRequestCount += 1; @@ -231,6 +232,7 @@ test("searches, paginates, and forgets plugin page records", async ({ await page.route( "**/api/plugins/memory/memories/memory-search", async (route) => { + forgetRequests += 1; expect(route.request().method()).toBe("DELETE"); forgotMemory = true; await route.fulfill({ status: 204 }); @@ -278,10 +280,16 @@ test("searches, paginates, and forgets plugin page records", async ({ await page .getByRole("button", { name: /^Deploy runbooks live in Notion/ }) .click(); - await page.getByRole("button", { name: "Forget this memory" }).click(); + await page + .getByRole("button", { name: "Forget this memory" }) + .evaluate((button) => { + button.click(); + button.click(); + }); await expect( page.getByText("No memories matched your search."), ).toBeVisible(); + expect(forgetRequests).toBe(1); await expect.poll(() => dashboardRequestCount).toBeGreaterThan(1); await searchbox.fill(""); diff --git a/packages/junior-dashboard/package.json b/packages/junior-dashboard/package.json index df1619ae1..528cc837f 100644 --- a/packages/junior-dashboard/package.json +++ b/packages/junior-dashboard/package.json @@ -26,6 +26,7 @@ "build:client": "tsup --config tsup.client.config.ts", "build:css": "tailwindcss -i src/tailwind.css -o dist/tailwind.css --minify", "build:server": "tsup --config tsup.config.ts", + "lint": "oxlint --config .oxlintrc.json --deny-warnings src", "prepare": "pnpm run build", "prepack": "pnpm run build", "test": "vitest run -c vitest.config.ts", @@ -49,10 +50,13 @@ }, "devDependencies": { "@tailwindcss/cli": "^4.3.0", + "@tanstack/eslint-plugin-query": "^5.101.4", "@types/node": "^25.9.1", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "4.1.7", + "eslint": "^10.8.0", + "oxlint": "^1.66.0", "tailwindcss": "^4.3.0", "tsup": "^8.5.1", "typescript": "^6.0.3", 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..71c7a126d 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,24 +58,27 @@ 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, }); } /** Fetch the conversation summary feed used by list-oriented dashboard routes. */ export function useConversationsData(actorEmail?: string) { - const query = new URLSearchParams(); - if (actorEmail) query.set("actorEmail", actorEmail); - const search = query.toString(); return useQuery({ queryKey: ["dashboard", "conversations", actorEmail ?? "all"], - queryFn: () => - fetchDashboardJson( + queryFn: ({ signal }) => { + const query = new URLSearchParams(); + if (actorEmail) query.set("actorEmail", actorEmail); + const search = query.toString(); + return fetchDashboardJson( conversationFeedSchema, `/api/conversations${search ? `?${search}` : ""}`, - ), + signal, + ); + }, 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/code.tsx b/packages/junior-dashboard/src/client/code.tsx index 43fae903e..3d6811fcf 100644 --- a/packages/junior-dashboard/src/client/code.tsx +++ b/packages/junior-dashboard/src/client/code.tsx @@ -1,10 +1,6 @@ import type { ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { - codeToHtml, - type BundledLanguage, - type DecorationItem, -} from "shiki/bundle/web"; +import { codeToHtml, type BundledLanguage } from "shiki/bundle/web"; import { cn } from "./styles"; import { @@ -19,31 +15,26 @@ export type ShikiHtml = string & { readonly [shikiHtmlBrand]: true }; /** * Render highlighted code while keeping Shiki output responsive in transcripts. - * Automatically merges externally-provided decorations (e.g. link spans) with - * any active transcript search highlights from context. + * Automatically applies active transcript search highlights from context. */ export function HighlightedCode(props: { code: string; - /** Extra Shiki decorations to apply in addition to search highlights. */ - decorations?: DecorationItem[]; language: BundledLanguage; }) { const search = useTranscriptSearch(); - const searchDecorations = search.active - ? buildSearchDecorations(props.code, search.normalizedQuery) - : []; - const allDecorations = [...(props.decorations ?? []), ...searchDecorations]; - + const normalizedQuery = search.active ? search.normalizedQuery : undefined; const highlighted = useQuery({ - queryKey: search.active - ? ["highlight", props.language, props.code, search.normalizedQuery] - : ["highlight", props.language, props.code], - queryFn: async (): Promise => - (await codeToHtml(props.code, { - decorations: allDecorations.length ? allDecorations : undefined, + queryKey: ["highlight", props.language, props.code, normalizedQuery], + queryFn: async (): Promise => { + const decorations = normalizedQuery + ? buildSearchDecorations(props.code, normalizedQuery) + : []; + return (await codeToHtml(props.code, { + decorations: decorations.length ? decorations : undefined, lang: props.language, theme: "github-dark", - })) as ShikiHtml, + })) as ShikiHtml; + }, staleTime: Infinity, }); diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index b1c2df0f2..3890460bc 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -329,13 +329,14 @@ export function useConversationData(conversationId: string | undefined) { }, [history.fetchNextPage, historyNeedsReconciliation]); return { - ...detail, data, + error: detail.error, historyError, historyVersion: conversationHistoryVersion(historyPages ?? []), hasPreviousPage: history.data ? history.hasNextPage : Boolean(detail.data?.previousCursor), + isPending: detail.isPending, isLoadingPreviousPage, loadCompleteTranscript: () => { if (!conversationId || !detail.data) { diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 950797b61..6ed02240f 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -497,7 +497,7 @@ export function totalConversationCost( ): CostUsageSummary | undefined { if (!summary && !auxiliaryCosts) return undefined; return { - ...(summary ?? {}), + ...summary, total: addCost(summary?.total ?? 0, auxiliaryCosts?.costUsd ?? 0), }; } diff --git a/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx b/packages/junior-dashboard/src/client/pages/PersonalTokensPage.tsx index fc8de2adf..c614d39ad 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,88 @@ 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 createTokenStarted = useRef(false); 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( - createdPersonalTokenSchema, + const cancelTokenListRefetch = () => + queryClient.cancelQueries({ + exact: true, + queryKey: personalTokensQueryKey, + }); + const tokensQuery = useQuery({ + queryKey: personalTokensQueryKey, + queryFn: ({ signal }) => + fetchDashboardJson( + personalTokenListSchema, "/api/personal-tokens", - { name }, + signal, + ), + retry: false, + }); + const createTokenMutation = useMutation({ + gcTime: 0, + mutationFn: (tokenName: string) => + post(createdPersonalTokenSchema, "/api/personal-tokens", { + name: tokenName, + }), + onMutate: () => cancelTokenListRefetch(), + onSuccess: async (created) => { + const { token: _token, ...metadata } = created; + await cancelTokenListRefetch(); + queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>( + personalTokensQueryKey, + (current) => ({ + tokens: [ + metadata, + ...(current?.tokens ?? []).filter( + (token) => token.id !== metadata.id, + ), + ], + }), ); - setTokens((current) => [token, ...current]); - setCreatedToken({ id: token.id, token: token.token }); - } catch { - setError("Could not create the API token. Try again."); - } finally { - setBusy(false); - } - } - - async function revokeToken(token: PersonalTokenMetadata) { - setBusy(true); - setError(undefined); - try { - await deleteDashboardResource( + }, + onSettled: () => { + createTokenStarted.current = false; + }, + }); + const revokeTokenMutation = useMutation({ + mutationFn: (token: PersonalTokenMetadata) => + deleteDashboardResource( `/api/personal-tokens/${encodeURIComponent(token.id)}`, + ), + onMutate: () => cancelTokenListRefetch(), + onSuccess: async (_result, token) => { + await cancelTokenListRefetch(); + 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); - } - } + 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 = + (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); return (
@@ -86,7 +115,7 @@ export function PersonalTokensPage() {
)} - {error ?

{error}

: null} + {displayedError ? ( +

{displayedError}

+ ) : null}
{loading ? ( @@ -142,7 +178,12 @@ 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={() => { + if (createTokenMutation.isError) { + createTokenMutation.reset(); + } + revokeTokenMutation.mutate(token); + }} type="button" > diff --git a/packages/junior-dashboard/src/client/pages/memory/MemoryPage.tsx b/packages/junior-dashboard/src/client/pages/memory/MemoryPage.tsx index 8c376a2df..85b4ac2af 100644 --- a/packages/junior-dashboard/src/client/pages/memory/MemoryPage.tsx +++ b/packages/junior-dashboard/src/client/pages/memory/MemoryPage.tsx @@ -23,6 +23,7 @@ import { Card } from "../../components/layout/Card"; import { PageHeader } from "../../components/layout/PageHeader"; import { type PluginUserPageRecord, + type PluginUserPageRecordAction, usePluginUserPageData, } from "../user/pluginUserPageData"; import { @@ -37,6 +38,12 @@ import { import { MemoryTimeline } from "./MemoryTimeline"; import { MemoryExtractionCost } from "./MemoryExtractionCost"; +type MemoryActionMutation = UseMutationResult< + void, + Error, + PluginUserPageRecordAction +>; + /** Render the temporary first-class dashboard experience for memory. */ export function MemoryPage(props: { page: PluginUserPageLink }) { const location = useLocation(); @@ -126,6 +133,7 @@ function MemoryLibrary(props: { page: PluginUserPageLink }) { filter, query, records, + runAction, searchQuery, searchText, setFilter, @@ -207,6 +215,7 @@ function MemoryLibrary(props: { page: PluginUserPageLink }) { action={action} first={index === 0} key={record.id} + onAction={runAction} onSelect={() => setSelectedRecordId((current) => current === record.id ? undefined : record.id, @@ -235,6 +244,7 @@ function MemoryLibrary(props: { page: PluginUserPageLink }) { setSelectedRecordId(undefined)} + onAction={runAction} record={selectedRecord} />
@@ -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 ? (