mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
## 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>
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
BookOpen,
|
|
LogOut,
|
|
Megaphone,
|
|
type LucideIcon,
|
|
UserRound,
|
|
UserRoundPen,
|
|
} from "lucide-react";
|
|
import type { DeploymentMode, ServerGitInfo } from "@paperclipai/shared";
|
|
import { Link } from "@/lib/router";
|
|
import { authApi } from "@/api/auth";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
|
import { ThemeToggle } from "./ThemeToggle";
|
|
import { SidebarServerInfo } from "./SidebarServerInfo";
|
|
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;
|
|
}
|
|
|
|
interface MenuActionProps {
|
|
label: string;
|
|
description: string;
|
|
icon: LucideIcon;
|
|
onClick?: () => void;
|
|
href?: string;
|
|
external?: boolean;
|
|
}
|
|
|
|
function deriveInitials(name: string) {
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
if (parts.length >= 2) {
|
|
return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase();
|
|
}
|
|
return name.slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) {
|
|
const candidates = [name, email?.split("@")[0], email, id];
|
|
for (const candidate of candidates) {
|
|
const slug = candidate
|
|
?.trim()
|
|
.toLowerCase()
|
|
.replace(/['"]/g, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
if (slug) return slug;
|
|
}
|
|
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";
|
|
|
|
const content = (
|
|
<>
|
|
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
|
|
<Icon className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-medium text-foreground">{label}</span>
|
|
<span className="block text-xs text-muted-foreground">{description}</span>
|
|
</span>
|
|
</>
|
|
);
|
|
|
|
if (href) {
|
|
if (external) {
|
|
return (
|
|
<a href={href} target="_blank" rel="noreferrer" className={className} onClick={onClick}>
|
|
{content}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link to={href} className={className} onClick={onClick}>
|
|
{content}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button type="button" className={className} onClick={onClick}>
|
|
{content}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function SidebarAccountMenu({
|
|
deploymentMode,
|
|
open: controlledOpen,
|
|
onOpenChange,
|
|
serverGit,
|
|
version,
|
|
}: SidebarAccountMenuProps) {
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const queryClient = useQueryClient();
|
|
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
|
const rail = collapsed && !peeking;
|
|
const open = controlledOpen ?? internalOpen;
|
|
const setOpen = onOpenChange ?? setInternalOpen;
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
retry: false,
|
|
});
|
|
|
|
const signOutMutation = useMutation({
|
|
mutationFn: () => authApi.signOut(),
|
|
onSuccess: async () => {
|
|
setOpen(false);
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
|
|
},
|
|
});
|
|
|
|
const displayName = session?.user.name?.trim() || "Board";
|
|
const secondaryLabel =
|
|
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
|
|
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);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}
|
|
|
|
return (
|
|
<div className="border-t border-r border-border bg-background px-3 py-2">
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-accent/50 hover:text-foreground"
|
|
aria-label="Open account menu"
|
|
>
|
|
<Avatar size="sm">
|
|
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
|
<AvatarFallback>{initials}</AvatarFallback>
|
|
</Avatar>
|
|
<span className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</span>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
side="top"
|
|
align="start"
|
|
sideOffset={10}
|
|
className="w-(--sz-277px) max-w-(--sz-calc-24) overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
|
|
>
|
|
<div className="h-24 bg-(image:--gradient-extract-25)" />
|
|
<div className="-mt-8 px-4 pb-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="rounded-2xl border-4 border-popover bg-popover p-0.5 shadow-sm">
|
|
<Avatar size="lg">
|
|
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
|
<AvatarFallback>{initials}</AvatarFallback>
|
|
</Avatar>
|
|
</div>
|
|
<div className="min-w-0 flex-1 pt-1">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
|
|
<Badge variant="ghost" className="bg-accent text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{accountBadge}
|
|
</Badge>
|
|
</div>
|
|
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
|
|
{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>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-1">
|
|
<MenuAction
|
|
label="View profile"
|
|
description="Open your activity, task, and usage ledger."
|
|
icon={UserRound}
|
|
href={profileHref}
|
|
onClick={closeNavigationChrome}
|
|
/>
|
|
<MenuAction
|
|
label="Edit profile"
|
|
description="Update your display name and avatar."
|
|
icon={UserRoundPen}
|
|
href={PROFILE_SETTINGS_PATH}
|
|
onClick={closeNavigationChrome}
|
|
/>
|
|
<MenuAction
|
|
label="Documentation"
|
|
description="Open Paperclip docs in a new tab."
|
|
icon={BookOpen}
|
|
href={DOCS_URL}
|
|
external
|
|
onClick={() => setOpen(false)}
|
|
/>
|
|
<MenuAction
|
|
label="Feedback"
|
|
description="Share feedback or report an issue."
|
|
icon={Megaphone}
|
|
href={FEEDBACK_URL}
|
|
external
|
|
onClick={() => setOpen(false)}
|
|
/>
|
|
<ThemeToggle variant="menu-action" onAfterToggle={() => setOpen(false)} />
|
|
{deploymentMode === "authenticated" ? (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-destructive/10",
|
|
signOutMutation.isPending && "cursor-not-allowed opacity-60",
|
|
)}
|
|
onClick={() => signOutMutation.mutate()}
|
|
disabled={signOutMutation.isPending}
|
|
>
|
|
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
|
|
<LogOut className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-medium text-foreground">
|
|
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
|
</span>
|
|
<span className="block text-xs text-muted-foreground">
|
|
End this browser session.
|
|
</span>
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
<SidebarServerInfo />
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|