diff --git a/packages/shared/src/types/server-info.ts b/packages/shared/src/types/server-info.ts index 852404d0cd..5c9ae793bb 100644 --- a/packages/shared/src/types/server-info.ts +++ b/packages/shared/src/types/server-info.ts @@ -18,6 +18,7 @@ export type ServerGitInfo = available: true; fullSha: string; shortSha: string; + branchName: string | null; subject: string; committedAt: string | null; localChanges: ServerGitLocalChanges; diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index ab2eeb0e38..9fbc47f3b4 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -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: { diff --git a/server/src/__tests__/server-info.test.ts b/server/src/__tests__/server-info.test.ts index a64b62bece..1af391d431 100644 --- a/server/src/__tests__/server-info.test.ts +++ b/server/src/__tests__/server-info.test.ts @@ -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"), diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 1c485cda35..1ad9b00cf1 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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([ diff --git a/server/src/server-info.ts b/server/src/server-info.ts index 369cb1435a..eb386be6c3 100644 --- a/server/src/server-info.ts +++ b/server/src/server-info.ts @@ -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, }; } diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index c694b81fbf..14b6f221fd 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -549,6 +549,7 @@ export function Layout() { @@ -568,6 +569,7 @@ export function Layout() { diff --git a/ui/src/components/SidebarAccountMenu.test.tsx b/ui/src/components/SidebarAccountMenu.test.tsx index 317d174319..bf36a3e5b4 100644 --- a/ui/src/components/SidebarAccountMenu.test.tsx +++ b/ui/src/components/SidebarAccountMenu.test.tsx @@ -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( + + + , + ); + }); + 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(); }); diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index 17a9394a26..f2dd6d439a 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -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({

{secondaryLabel}

- {version ? ( + {sourceSha && sourceFullSha ? ( +
+ {sourceBranch ? ( + + {sourceBranch} + + ) : null} +

+ Paperclip{" "} + + {sourceSha.slice(0, 7)} + +

+
+ ) : version ? (

Paperclip v{version}

) : null} diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index 75da3d1cd2..a8340528fa 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -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 }); }, }); diff --git a/ui/src/components/SidebarServerInfo.test.tsx b/ui/src/components/SidebarServerInfo.test.tsx index 5f4f8ef718..7b29ef1402 100644 --- a/ui/src/components/SidebarServerInfo.test.tsx +++ b/ui/src/components/SidebarServerInfo.test.tsx @@ -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: { diff --git a/ui/src/pages/Auth.test.tsx b/ui/src/pages/Auth.test.tsx index 47be067637..59d70be809 100644 --- a/ui/src/pages/Auth.test.tsx +++ b/ui/src/pages/Auth.test.tsx @@ -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(); + }); + }); }); diff --git a/ui/src/pages/Auth.tsx b/ui/src/pages/Auth.tsx index 20a8c542db..6948254782 100644 --- a/ui/src/pages/Auth.tsx +++ b/ui/src/pages/Auth.tsx @@ -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 }); }, diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index c25d197069..3737e95c04 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -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."); diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 2c06666f0a..b540fc7528 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -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);