mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
feat(ui): show running commit in staging account menu (#14410)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The account menu shows the signed-in user's identity. > - Staging users need to know which server commit is running after a deploy. > - The health endpoint already returns that commit, but the menu does not show it. > - This pull request adds the short commit below the email on staging hosts. > - Users can open the menu to check a deploy without opening deployment tools. ## Linked Issues or Issue Description **What existing behavior does this improve?** The account menu on staging instances. **Current behavior** The menu shows the user's name and email. It does not show the running server commit. **Proposed behavior** On `*.staging.paperclip.app`, show `SHA 8751e2d` below the email. Use the current `/api/health` commit. Show the full SHA on hover and link to the commit on GitHub. Refresh the health query when the menu opens. Hide the label on other hosts and when commit metadata is unavailable. **Reason and benefit** A user can confirm which commit a staging instance runs after an automatic deploy. **Breaking changes** None. The server already returns the commit field. Refs #14060 for related account-menu work. This change adds deployment information only. ## What Changed - Add the existing health response commit field to the UI type. - Share the staging host check and a separate health query across both account-menu variants. A menu refresh failure leaves the access gate health state unchanged. - Show a short SHA below the email. Link to the full commit on GitHub and include the full SHA in its accessible name and hover title. - Document the staging label and cover staging hosts, other hosts, missing metadata, refresh on reopen, and request failure isolation. ## Verification - `pnpm exec vitest run ui/src/components/SidebarAccountMenu.test.tsx` — 24 tests passed. - `pnpm check:token-gates` — passed. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. The UI build and typecheck also passed again after review fixes. - Full test matrix — passed on the latest commit in [CI](https://github.com/paperclipai/paperclip/actions/runs/36447810742). The local `pnpm test:run` was stopped before completion after CI finished the same suites. The focused local tests, full local typecheck, and full local build passed. - Browser shard 6 passed on one rerun. Its first attempt lost part of the draft text in the existing attachment-receipt reload test. No code changed for the rerun. - Rendered the real account menu in a local browser fixture with a staging hostname condition and mocked health response. Confirmed the SHA fits below the email in the dark menu. - Greptile review — 5/5, all review threads resolved. - Manual check after deployment: open the menu on a staging host and compare the SHA with `/api/health`. Open the menu again after a deploy to refresh it. Confirm the label is absent on production and localhost. ## Risks - Low risk. Each menu opening on staging can make one additional health request. - The host check applies to `*.staging.paperclip.app`. Other staging domains will need an explicit update. - The label identifies the running server commit. It can briefly show cached data while the request completes. ## Model Used OpenAI Codex, GPT-6. The exact model variant and context window are not exposed in this session. Used code execution, repository inspection, and browser inspection. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #123` / `Refs #123` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e9debd3eac
commit
270afd2fb8
7 files changed
+176
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
<div className="min-w-0 flex-1 pt-1">
|
||||
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
|
||||
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
|
||||
{stagingCommit ? (
|
||||
<a
|
||||
className="block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
|
||||
href={`https://github.com/paperclipai/paperclip/commit/${stagingCommit}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`View commit ${stagingCommit} on GitHub`}
|
||||
title={stagingCommit}
|
||||
>
|
||||
SHA {stagingCommit.slice(0, 7)}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider><AccountMenu open /></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider><AccountMenu open={open} /></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider><AccountMenu open /></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider><AccountMenu open /></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
expect(document.body.textContent).not.toContain("SHA ");
|
||||
await act(() => root.unmount());
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
|
||||
it("shares the nav background without separator borders", async () => {
|
||||
|
||||
@@ -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({
|
||||
<p className="truncate text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground">
|
||||
{secondaryLabel}
|
||||
</p>
|
||||
{stagingCommit ? (
|
||||
<a
|
||||
className="block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
|
||||
href={`https://github.com/paperclipai/paperclip/commit/${stagingCommit}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`View commit ${stagingCommit} on GitHub`}
|
||||
title={stagingCommit}
|
||||
>
|
||||
SHA {stagingCommit.slice(0, 7)}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user