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({
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,
},