fix(ui): show source SHA for unreleased builds (#9508)

## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies
> - Operators need to identify the exact build running from the
persistent account menu
> - Formal releases already have a concise public version, but source
builds include a long derived version string
> - The derived version identifies a commit but does not expose the
source branch or a direct path to inspect the code
> - Server Git metadata is auth-sensitive, so the UI must also refresh
it when the current session changes
> - This pull request shows linked branch and commit metadata for source
builds while preserving `v<version>` for formal releases
> - The benefit is faster build diagnosis with correct metadata across
sign-in and sign-out transitions

## Linked Issues or Issue Description

### Pre-submission checklist

- [x] I searched existing open and closed issues and found no duplicate
implementing this exact account-menu behavior.
- [x] The behavior reproduces on `master`.
- [x] The behavior originates in Paperclip's core UI, not an adapter,
provider, or local configuration.

### What happened?

Source builds displayed the full derived version, such as
`2026.626.0+58.git.518fc71ce`, without linking the operator to the
corresponding source branch or commit.

### Expected behavior

Source builds should show the concise branch and short commit SHA with
links to GitHub, while formal releases should continue showing their
public version. Auth transitions should refresh the health metadata that
supplies those Git details.

### Steps to reproduce

1. Run Paperclip from a commit after a release tag.
2. Open the account menu.
3. Inspect the build label beneath the user identity.
4. Sign in or out and reopen the menu.

### Paperclip version or commit

Any source build whose server version uses the
`<version>+<count>.git.<sha>[.dirty]` format.

### Deployment mode

Local dev (`pnpm dev`) or authenticated deployments.

### Installation method

Built from source.

## What Changed

- Detect source-derived version strings and render the source branch
plus seven-character commit SHA in `SidebarAccountMenu`.
- Link source branches and commits to the canonical
`paperclipai/paperclip` GitHub repository.
- Extend server Git metadata with the full SHA and expose it through
health/OpenAPI contracts.
- Refresh auth-sensitive health metadata after sign-in and every
sign-out entry point.
- Preserve the existing `v<version>` label for formal releases and add
focused regression coverage.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run src/pages/Auth.test.tsx
src/components/SidebarAccountMenu.test.tsx
src/components/SidebarServerInfo.test.tsx`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/health.test.ts src/__tests__/server-info.test.ts`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`
- `git diff --check public/master...HEAD`

## Risks

- Low risk: formal release rendering retains the existing fallback
behavior when the source-version pattern does not match.
- Source links assume the build came from the canonical public
repository; fork-only branches or commits may not resolve there.
- Health metadata is invalidated after auth transitions, adding one
bounded refetch so the displayed Git details match the new session.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex using GPT-5.4 with medium reasoning, repository/tool
access, shell execution, and code editing; context-window size was not
exposed by the runtime.

## 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
#` / `Refs #` 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:
Dotta 2026-07-13 20:55:54 -05:00 committed by GitHub
parent efcce9cc8e
commit 6f204605ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 222 additions and 14 deletions

View File

@ -18,6 +18,7 @@ export type ServerGitInfo =
available: true;
fullSha: string;
shortSha: string;
branchName: string | null;
subject: string;
committedAt: string | null;
localChanges: ServerGitLocalChanges;

View File

@ -16,6 +16,7 @@ const testServerInfo = {
available: true,
fullSha: "0123456789abcdef0123456789abcdef01234567",
shortSha: "0123456",
branchName: "master",
subject: "Add server info debug view",
committedAt: "2026-06-25T23:00:00.000Z",
localChanges: {

View File

@ -21,6 +21,7 @@ describe("server info snapshot", () => {
"Add server info debug view",
"2026-06-25T17:00:00-07:00",
].join("\n"),
gitBranchCommand: () => "feature/server-info\n",
gitStatusCommand: () => "",
});
@ -30,6 +31,7 @@ describe("server info snapshot", () => {
available: true,
fullSha: "0123456789abcdef0123456789abcdef01234567",
shortSha: "0123456",
branchName: "feature/server-info",
subject: "Add server info debug view",
committedAt: "2026-06-26T00:00:00.000Z",
localChanges: {
@ -98,6 +100,29 @@ describe("server info snapshot", () => {
});
});
it("keeps commit metadata available when HEAD is detached", () => {
const snapshot = createServerInfoSnapshot({
now: new Date("2026-06-26T00:00:00.000Z"),
gitCommand: () =>
[
"0123456789abcdef0123456789abcdef01234567",
"0123456",
"Add server info debug view",
"2026-06-25T17:00:00-07:00",
].join("\n"),
gitBranchCommand: () => {
throw new Error("detached HEAD");
},
gitStatusCommand: () => "",
});
expect(snapshot.git).toMatchObject({
available: true,
branchName: null,
shortSha: "0123456",
});
});
it("uses sanitized fallback metadata when git is unavailable", () => {
const snapshot = createServerInfoSnapshot({
now: new Date("2026-06-26T00:00:00.000Z"),

View File

@ -930,6 +930,7 @@ registry.registerPath({
available: z.literal(true),
fullSha: z.string(),
shortSha: z.string(),
branchName: z.string().nullable(),
subject: z.string(),
committedAt: z.string().datetime().nullable(),
localChanges: z.union([

View File

@ -32,6 +32,18 @@ function defaultGitStatusCommand() {
);
}
function defaultGitBranchCommand() {
return execFileSync(
"git",
["symbolic-ref", "--quiet", "--short", "HEAD"],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1500,
},
);
}
function parseGitLocalChanges(output: string): ServerGitLocalChanges {
let stagedFileCount = 0;
let unstagedFileCount = 0;
@ -67,7 +79,11 @@ function getGitLocalChanges(gitStatusCommand: GitCommand): ServerGitLocalChanges
}
}
function parseGitInfo(output: string, localChanges: ServerGitLocalChanges): ServerGitInfo {
function parseGitInfo(
output: string,
branchName: string | null,
localChanges: ServerGitLocalChanges,
): ServerGitInfo {
const [fullSha = "", shortSha = "", subject = "", committedAt = ""] = output
.trimEnd()
.split("\n");
@ -81,6 +97,7 @@ function parseGitInfo(output: string, localChanges: ServerGitLocalChanges): Serv
available: true,
fullSha,
shortSha,
branchName,
subject: subject.trim() || "No commit subject",
committedAt: Number.isNaN(committedAtTime) ? null : new Date(committedAtTime).toISOString(),
localChanges,
@ -90,22 +107,29 @@ function parseGitInfo(output: string, localChanges: ServerGitLocalChanges): Serv
function readGitInfo(
gitCommand: GitCommand = defaultGitCommand,
gitStatusCommand: GitCommand = defaultGitStatusCommand,
gitBranchCommand: GitCommand = defaultGitBranchCommand,
): ServerGitInfo {
try {
const output = gitCommand();
const localChanges = getGitLocalChanges(gitStatusCommand);
return parseGitInfo(output, localChanges);
let branchName: string | null = null;
try {
branchName = gitBranchCommand().trim() || null;
} catch {
branchName = null;
}
return parseGitInfo(output, branchName, localChanges);
} catch {
return { available: false, unavailableReason: "git_unavailable" };
}
}
export function createServerInfoSnapshot(
opts: { now?: Date; gitCommand?: GitCommand; gitStatusCommand?: GitCommand } = {},
opts: { now?: Date; gitCommand?: GitCommand; gitStatusCommand?: GitCommand; gitBranchCommand?: GitCommand } = {},
): ServerInfoSnapshot {
return {
processStartedAt: (opts.now ?? new Date()).toISOString(),
git: readGitInfo(opts.gitCommand, opts.gitStatusCommand),
git: readGitInfo(opts.gitCommand, opts.gitStatusCommand, opts.gitBranchCommand),
};
}
@ -119,12 +143,12 @@ const processStartedAt = new Date().toISOString();
let gitInfoCache: { value: ServerGitInfo; expiresAt: number } | null = null;
export function getServerInfoSnapshot(
opts: { now?: number; gitCommand?: GitCommand; gitStatusCommand?: GitCommand } = {},
opts: { now?: number; gitCommand?: GitCommand; gitStatusCommand?: GitCommand; gitBranchCommand?: GitCommand } = {},
): ServerInfoSnapshot {
const now = opts.now ?? Date.now();
if (!gitInfoCache || now >= gitInfoCache.expiresAt) {
gitInfoCache = {
value: readGitInfo(opts.gitCommand, opts.gitStatusCommand),
value: readGitInfo(opts.gitCommand, opts.gitStatusCommand, opts.gitBranchCommand),
expiresAt: now + GIT_INFO_CACHE_TTL_MS,
};
}

View File

@ -549,6 +549,7 @@ export function Layout() {
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
serverGit={health?.serverInfo?.git}
version={health?.version}
/>
</div>
@ -568,6 +569,7 @@ export function Layout() {
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
serverGit={health?.serverInfo?.git}
version={health?.version}
/>
</SidebarShell>

View File

@ -3,6 +3,7 @@
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import { SidebarAccountMenu } from "./SidebarAccountMenu";
const mockAuthApi = vi.hoisted(() => ({
@ -85,6 +86,7 @@ describe("SidebarAccountMenu", () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
});
mockAuthApi.signOut.mockResolvedValue(undefined);
});
afterEach(() => {
@ -98,6 +100,10 @@ describe("SidebarAccountMenu", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.health, {
status: "ok",
deploymentMode: "authenticated",
});
await act(async () => {
root.render(
@ -147,6 +153,65 @@ describe("SidebarAccountMenu", () => {
.toContain("w-(--sz-277px)");
expect(document.body.querySelector('a[href="/company/settings/instance/profile"]')).not.toBeNull();
const signOutButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Sign out"),
);
await act(async () => {
signOutButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockAuthApi.signOut).toHaveBeenCalledOnce();
expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true);
await act(async () => {
root.unmount();
});
});
it("shows the short commit sha instead of a version for source builds", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarAccountMenu
deploymentMode="authenticated"
version="2026.626.0+58.git.518fc71ce"
serverGit={{
available: true,
fullSha: "518fc71ce1234567890abcdef1234567890abcde",
shortSha: "518fc71",
branchName: "feature/source-build-label",
subject: "Show source build label",
committedAt: "2026-06-26T00:00:00.000Z",
localChanges: {
available: true,
hasLocalChanges: false,
stagedFileCount: 0,
unstagedFileCount: 0,
untrackedFileCount: 0,
},
}}
open
/>
</QueryClientProvider>,
);
});
await flushReact();
expect(document.body.textContent).toContain("feature/source-build-labelPaperclip 518fc71");
expect(document.body.textContent).not.toContain("2026.626.0+58.git.518fc71ce");
expect(document.body.querySelector('a[href="https://github.com/paperclipai/paperclip/tree/feature%2Fsource-build-label"]')?.textContent).toBe(
"feature/source-build-label",
);
expect(document.body.querySelector('a[href="https://github.com/paperclipai/paperclip/commit/518fc71ce1234567890abcdef1234567890abcde"]')?.textContent).toBe(
"518fc71",
);
await act(async () => {
root.unmount();
});

View File

@ -8,7 +8,7 @@ import {
UserRound,
UserRoundPen,
} from "lucide-react";
import type { DeploymentMode } from "@paperclipai/shared";
import type { DeploymentMode, ServerGitInfo } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
@ -23,11 +23,14 @@ import { Badge } from "@/components/ui/badge";
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
const DOCS_URL = "https://docs.paperclip.ing/";
const FEEDBACK_URL = "https://paperclip.ing/feedback";
const SOURCE_REPOSITORY_URL = "https://github.com/paperclipai/paperclip";
const SOURCE_VERSION_RE = /\+\d+\.git\.([0-9a-f]{7,40})(?:\.dirty)?$/i;
interface SidebarAccountMenuProps {
deploymentMode?: DeploymentMode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
serverGit?: ServerGitInfo;
version?: string | null;
}
@ -62,6 +65,11 @@ function deriveUserSlug(name: string | null | undefined, email: string | null |
return "me";
}
function sourceVersionSha(version: string): string | null {
const sourceVersion = version.match(SOURCE_VERSION_RE);
return sourceVersion?.[1] ?? null;
}
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
const className =
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
@ -105,6 +113,7 @@ export function SidebarAccountMenu({
deploymentMode,
open: controlledOpen,
onOpenChange,
serverGit,
version,
}: SidebarAccountMenuProps) {
const [internalOpen, setInternalOpen] = useState(false);
@ -124,6 +133,7 @@ export function SidebarAccountMenu({
onSuccess: async () => {
setOpen(false);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
},
});
@ -133,6 +143,12 @@ export function SidebarAccountMenu({
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
const initials = deriveInitials(displayName);
const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
const sourceSha = version ? sourceVersionSha(version) : null;
const sourceFullSha =
sourceSha && serverGit?.available && serverGit.fullSha.toLowerCase().startsWith(sourceSha.toLowerCase())
? serverGit.fullSha
: sourceSha;
const sourceBranch = sourceSha && serverGit?.available ? serverGit.branchName : null;
function closeNavigationChrome() {
setOpen(false);
@ -178,7 +194,31 @@ export function SidebarAccountMenu({
</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
{version ? (
{sourceSha && sourceFullSha ? (
<div className="mt-1 text-xs text-muted-foreground">
{sourceBranch ? (
<a
href={`${SOURCE_REPOSITORY_URL}/tree/${encodeURIComponent(sourceBranch)}`}
target="_blank"
rel="noreferrer"
className="block truncate transition-colors hover:text-foreground"
>
{sourceBranch}
</a>
) : null}
<p>
Paperclip{" "}
<a
href={`${SOURCE_REPOSITORY_URL}/commit/${sourceFullSha}`}
target="_blank"
rel="noreferrer"
className="transition-colors hover:text-foreground"
>
{sourceSha.slice(0, 7)}
</a>
</p>
</div>
) : version ? (
<p className="mt-1 text-xs text-muted-foreground">Paperclip v{version}</p>
) : null}
</div>

View File

@ -169,6 +169,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
setOpen(false);
if (isMobile) setSidebarOpen(false);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
},
});

View File

@ -112,6 +112,7 @@ describe("SidebarServerInfo", () => {
available: true,
fullSha: "abcdef1234567890abcdef1234567890abcdef12",
shortSha: "abcdef1",
branchName: "master",
subject: "Add server info debug view",
committedAt: "2026-06-25T23:00:00.000Z",
localChanges: {
@ -160,6 +161,7 @@ describe("SidebarServerInfo", () => {
available: true,
fullSha: "abcdef1234567890abcdef1234567890abcdef12",
shortSha: "abcdef1",
branchName: "master",
subject: "Add server info debug view",
committedAt: "2026-06-25T23:00:00.000Z",
localChanges: {
@ -204,6 +206,7 @@ describe("SidebarServerInfo", () => {
available: true,
fullSha: "abcdef1234567890abcdef1234567890abcdef12",
shortSha: "abcdef1",
branchName: "master",
subject: "Add server info debug view",
committedAt: "2026-06-25T23:00:00.000Z",
localChanges: {
@ -233,6 +236,7 @@ describe("SidebarServerInfo", () => {
available: true,
fullSha: "1111111111111111111111111111111111111111",
shortSha: "1111111",
branchName: "master",
subject: "First boot",
committedAt: "2026-06-25T23:00:00.000Z",
localChanges: {
@ -279,6 +283,7 @@ describe("SidebarServerInfo", () => {
available: true,
fullSha: "2222222222222222222222222222222222222222",
shortSha: "2222222",
branchName: "master",
subject: "After restart",
committedAt: "2026-06-26T01:30:00.000Z",
localChanges: {

View File

@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import { AuthPage } from "./Auth";
const getSessionMock = vi.hoisted(() => vi.fn());
@ -110,11 +111,11 @@ describe("AuthPage", () => {
});
await flushReact();
await flushReact();
return root;
return { root, queryClient };
}
it("exposes password-manager metadata and a11y attributes on the sign-in form", async () => {
const root = await mount();
const { root } = await mount();
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
@ -147,7 +148,7 @@ describe("AuthPage", () => {
});
it("uses new-password autocomplete in sign-up mode", async () => {
const root = await mount();
const { root } = await mount();
const createOne = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Create one",
@ -172,7 +173,7 @@ describe("AuthPage", () => {
});
it("renders auth errors in an assertive alert region referenced by the inputs", async () => {
const root = await mount();
const { root } = await mount();
const inputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
@ -213,4 +214,43 @@ describe("AuthPage", () => {
root.unmount();
});
});
it("invalidates anonymous health metadata after sign-in", async () => {
const { root, queryClient } = await mount();
queryClient.setQueryData(queryKeys.health, {
status: "ok",
deploymentMode: "authenticated",
});
const inputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
await act(async () => {
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
inputValueSetter!.call(passwordInput, "supersecret");
passwordInput.dispatchEvent(new Event("input", { bubbles: true }));
});
const form = container.querySelector("form") as HTMLFormElement;
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
await flushReact();
expect(signInEmailMock).toHaveBeenCalledWith({
email: "jane@example.com",
password: "supersecret",
});
expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true);
await act(async () => {
root.unmount();
});
});
});

View File

@ -53,6 +53,7 @@ export function AuthPage() {
onSuccess: async () => {
setError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
navigate(nextPath, { replace: true });
},

View File

@ -28,8 +28,9 @@ export function InstanceGeneralSettings() {
const signOutMutation = useMutation({
mutationFn: () => authApi.signOut(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to sign out.");

View File

@ -374,6 +374,7 @@ export function InviteLandingPage() {
setAuthFeedback(null);
rememberPendingInviteToken(token);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess });
const { companies: freshCompanies } = await queryClient.fetchQuery(companiesListQueryOptions);