diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 85e38bd8160..417715fc925 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1289,6 +1289,12 @@ Expected: - `/api/health` returns `{"status":"ok"}` - `/api/companies` returns a JSON array +On `*.staging.paperclip.app`, the account menu shows the running server's +short commit SHA below the user's email. Hover over it for the full SHA, +or follow the link to inspect the commit on GitHub. +Opening the menu refreshes `/api/health` so the label reflects recent deploys. +The label is hidden on other hosts and when commit metadata is unavailable. + ## Reset Local Dev Database To wipe local dev data and start fresh: diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index ac8cbc00a2b..df5c3b50319 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -26,6 +26,8 @@ export type CloudInstanceHealthStatus = { export type HealthStatus = { status: "ok"; version?: string; + /** Commit of the running server; null when build metadata is unavailable. */ + commit?: string | null; deploymentMode?: "local_trusted" | "authenticated"; deploymentExposure?: "private" | "public"; localAiLoginSupported?: boolean; diff --git a/ui/src/components/SidebarAccountMenu.production.tsx b/ui/src/components/SidebarAccountMenu.production.tsx index c96aa2e2d1e..005a31ad9f7 100644 --- a/ui/src/components/SidebarAccountMenu.production.tsx +++ b/ui/src/components/SidebarAccountMenu.production.tsx @@ -14,6 +14,7 @@ import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useSignOut } from "@/hooks/useSignOut"; +import { useStagingCommit } from "@/hooks/useStagingCommit"; import { useSidebar } from "../context/SidebarContext"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -112,6 +113,7 @@ export function SidebarAccountMenu({ const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking; const open = controlledOpen ?? internalOpen; + const stagingCommit = useStagingCommit(open); const setOpen = onOpenChange ?? setInternalOpen; const { data: session } = useQuery({ queryKey: queryKeys.auth.session, @@ -174,6 +176,18 @@ export function SidebarAccountMenu({

{displayName}

{secondaryLabel}

+ {stagingCommit ? ( + + SHA {stagingCommit.slice(0, 7)} + + ) : null}
diff --git a/ui/src/components/SidebarAccountMenu.test.tsx b/ui/src/components/SidebarAccountMenu.test.tsx index dab607771c3..8fd2eeaaafc 100644 --- a/ui/src/components/SidebarAccountMenu.test.tsx +++ b/ui/src/components/SidebarAccountMenu.test.tsx @@ -19,6 +19,7 @@ const mockAuthApi = vi.hoisted(() => ({ const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), })); +const mockHealthApi = vi.hoisted(() => ({ get: vi.fn() })); const mockToggleTheme = vi.hoisted(() => vi.fn()); const mockSetSidebarOpen = vi.hoisted(() => vi.fn()); const mockNavigateTopLevel = vi.hoisted(() => vi.fn()); @@ -27,6 +28,8 @@ vi.mock("@/api/auth", () => ({ authApi: mockAuthApi, })); +vi.mock("@/api/health", () => ({ healthApi: mockHealthApi })); + vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: mockNavigateTopLevel, })); @@ -100,6 +103,124 @@ describe("SidebarAccountMenu", () => { container.remove(); document.body.innerHTML = ""; vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + describe.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("staging commit (%#)", (AccountMenu) => { + const commit = "8751e2de4626ff5e7355fe28b30509991cfff920"; + + it.each([ + ["paperclip.staging.paperclip.app", true], + ["another.staging.paperclip.app", true], + ["paperclip.paperclip.app", false], + ["localhost", false], + ["paperclip.staging.paperclip.app.example.com", false], + ])("shows the running SHA only on staging: %s", async (hostname, visible) => { + vi.stubGlobal("location", new URL(`https://${hostname}`)); + mockHealthApi.get.mockResolvedValue({ status: "ok", commit }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + // Production must stay hidden even if the shared cache contains a commit. + queryClient.setQueryData(queryKeys.health, { status: "ok", commit }); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const label = document.body.querySelector(`a[title="${commit}"]`); + expect(Boolean(label)).toBe(visible); + if (visible) { + expect(label?.textContent).toBe("SHA 8751e2d"); + expect(label?.previousElementSibling?.textContent).toBe("jane@example.com"); + expect(label?.getAttribute("href")).toBe(`https://github.com/paperclipai/paperclip/commit/${commit}`); + expect(label?.getAttribute("aria-label")).toBe(`View commit ${commit} on GitHub`); + expect(mockHealthApi.get).toHaveBeenCalledOnce(); + } else { + expect(mockHealthApi.get).not.toHaveBeenCalled(); + } + await act(() => root.unmount()); + queryClient.clear(); + }); + + it("refreshes the running SHA each time the staging menu opens", async () => { + vi.stubGlobal("location", new URL("https://paperclip.staging.paperclip.app")); + const nextCommit = "3447609d2247e75e55d91493dda91a608364f672"; + mockHealthApi.get + .mockResolvedValueOnce({ status: "ok", commit }) + .mockResolvedValueOnce({ status: "ok", commit: nextCommit }); + const queryClient = new QueryClient(); + const root = createRoot(container); + const renderMenu = async (open: boolean) => { + await act(() => { + root.render( + + + , + ); + }); + await flushReact(); + }; + + await renderMenu(false); + expect(mockHealthApi.get).not.toHaveBeenCalled(); + await renderMenu(true); + expect(document.body.textContent).toContain("SHA 8751e2d"); + await renderMenu(false); + await renderMenu(true); + expect(document.body.textContent).toContain("SHA 3447609"); + expect(document.body.textContent).not.toContain("SHA 8751e2d"); + expect(mockHealthApi.get).toHaveBeenCalledTimes(2); + await act(() => root.unmount()); + queryClient.clear(); + }); + + it("does not change the board health state when a menu refresh fails", async () => { + vi.stubGlobal("location", new URL("https://paperclip.staging.paperclip.app")); + mockHealthApi.get.mockRejectedValueOnce(new Error("Deploy in progress")); + const queryClient = new QueryClient(); + const boardHealth = { status: "ok", commit }; + queryClient.setQueryData(queryKeys.health, boardHealth); + queryClient.setQueryData(queryKeys.stagingCommit, boardHealth); + const root = createRoot(container); + await act(() => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(mockHealthApi.get).toHaveBeenCalledOnce(); + expect(queryClient.getQueryState(queryKeys.stagingCommit)?.status).toBe("error"); + expect(queryClient.getQueryState(queryKeys.health)?.status).toBe("success"); + expect(queryClient.getQueryData(queryKeys.health)).toEqual(boardHealth); + expect(document.body.textContent).not.toContain("SHA "); + await act(() => root.unmount()); + queryClient.clear(); + }); + + it.each([null, undefined])("omits unavailable commit metadata (%s)", async (commit) => { + vi.stubGlobal("location", new URL("https://paperclip.staging.paperclip.app")); + mockHealthApi.get.mockResolvedValue({ status: "ok", commit }); + const queryClient = new QueryClient(); + const root = createRoot(container); + await act(() => { + root.render( + + + , + ); + }); + await flushReact(); + expect(document.body.textContent).not.toContain("SHA "); + await act(() => root.unmount()); + queryClient.clear(); + }); }); it("shares the nav background without separator borders", async () => { diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index 199e8cbeeea..d6552c3a509 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -15,6 +15,7 @@ import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useSignOut } from "@/hooks/useSignOut"; +import { useStagingCommit } from "@/hooks/useStagingCommit"; import { useSidebar } from "../context/SidebarContext"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -118,6 +119,7 @@ export function SidebarAccountMenu({ const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking && !forceExpanded; const open = controlledOpen ?? internalOpen; + const stagingCommit = useStagingCommit(open); const setOpen = onOpenChange ?? setInternalOpen; const { data: session } = useQuery({ queryKey: queryKeys.auth.session, @@ -180,6 +182,18 @@ export function SidebarAccountMenu({

{secondaryLabel}

+ {stagingCommit ? ( + + SHA {stagingCommit.slice(0, 7)} + + ) : null} diff --git a/ui/src/hooks/useStagingCommit.ts b/ui/src/hooks/useStagingCommit.ts new file mode 100644 index 00000000000..ca68b0bfaf3 --- /dev/null +++ b/ui/src/hooks/useStagingCommit.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { healthApi } from "@/api/health"; +import { queryKeys } from "@/lib/queryKeys"; + +export function useStagingCommit(menuOpen: boolean) { + const isStaging = window.location.hostname.endsWith(".staging.paperclip.app"); + const { data, isError } = useQuery({ + // Keep optional menu refresh failures separate from the board's access gate. + queryKey: queryKeys.stagingCommit, + queryFn: () => healthApi.get(), + enabled: isStaging && menuOpen, + // Opening the menu must refresh the server SHA after a staging rollout. + staleTime: 0, + retry: false, + }); + + return isStaging && !isError ? data?.commit ?? null : null; +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 45d03d80db5..56c4879b837 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -615,6 +615,7 @@ export const queryKeys = { experimentalSettings: ["instance", "experimental-settings"] as const, }, health: ["health"] as const, + stagingCommit: ["staging-commit"] as const, cloud: { stacks: ["cloud", "stacks"] as const, },