mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/222/changes/paperclip/222
<!-- entire-trail-link-end -->
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI has an account button at the bottom of the sidebar. It
opens a popover with Settings, Invite, Documentation, and the theme
switch.
> - The popover has a fixed minimum height of 304px. Its content is only
257px tall.
> - Because of this, the popover shows about 50px of empty space below
the last item. It looks broken.
> - This pull request removes the minimum height so the popover fits its
content. It also makes the options padding equal on all sides.
> - The benefit is a tidy menu that ends right after the last item, with
even padding.
## Linked Issues or Issue Description
No public issue exists. This is a bug description.
**What happened?**
The sidebar account popover shows a block of empty space (about 50px)
below "Switch to light mode", the last item.
**Expected behavior**
The popover ends right after the last item. The bottom padding is the
same as the top and side padding.
**Steps to reproduce**
1. Open the board UI.
2. Click the account button at the bottom-left of the sidebar.
3. Look at the space below the last menu item.
**Paperclip version or commit**
`master` at 71f0c0c99.
**Additional context**
The cause is `min-h-(--profile-popover-min-height)` (304px) on the
popover content, plus `flex-1` on the options list, which stretches the
list into the extra space.
## What Changed
- `ui/src/components/SidebarAccountMenu.tsx`: remove
`min-h-(--profile-popover-min-height)` from the popover content, remove
`flex-1` from the options list, and change the options padding from
`px-2.5 pb-2.5 pt-2` to `p-2.5`.
- `ui/src/index.css`: remove the unused `--profile-popover-min-height`
token.
- `ui/src/components/SidebarAccountMenu.test.tsx`: the test now fails if
any `min-h-` class comes back on the popover.
## Verification
- `pnpm check:token-gates`: all gates clean.
- `cd ui && npx vitest run src/components/SidebarAccountMenu.test.tsx`:
36/36 pass.
- Manual: open the account popover in dark and light mode. The gap below
the last item is now 11px (10px padding plus the 1px border). Before the
fix it was 60px. The header, items, and trigger do not change. The items
move down 2px because the top padding is now 10px.
- Snapshot baselines are intentionally not updated. Per-change snapshot
verification is dormant per the `doc/design/DECISION-SHEET.md` entry
"Per-change snapshot verification demoted to dormant (Jul 13 2026)".
## Risks
- Low risk. The change is CSS only, in one component. The popover is now
shorter, so it opens closer to the trigger. If a future item list is
long, the popover grows with its content as before.
## Model Used
- Claude (Anthropic), model ID `claude-opus-5-5` (Opus 5.5), via Claude
Code with tool use and code execution. Standard context window, adaptive
reasoning.
## 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import { useState, type CSSProperties } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
BookOpen,
|
|
Flag,
|
|
LogOut,
|
|
Settings,
|
|
type LucideIcon,
|
|
UserPlus,
|
|
} from "lucide-react";
|
|
import { hidesCompanyPage, type DeploymentMode } from "@paperclipai/shared";
|
|
import { Link } from "@/lib/router";
|
|
import { authApi } from "@/api/auth";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
|
import { useCloudInviteUrl } from "@/hooks/useCloudInviteUrl";
|
|
import { useCanInviteCompanyMembers } from "@/hooks/useCompanyInviteAccess";
|
|
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
|
import { useSignOut } from "@/hooks/useSignOut";
|
|
import { useStagingCommit } from "@/hooks/useStagingCommit";
|
|
import { userProfilePath } from "@/lib/userProfileLinks";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
|
import { FlyoutContent, FlyoutHeader, FlyoutOptions } from "@/components/ui/flyout";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
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 { TruncatedText } from "./TruncatedText";
|
|
|
|
const INVITES_PATH = "/company/settings/members?tab=invites";
|
|
const DOCS_URL = "https://docs.paperclip.ing/";
|
|
const FEEDBACK_URL = "https://paperclip.ing/feedback";
|
|
|
|
interface SidebarAccountMenuProps {
|
|
deploymentMode?: DeploymentMode;
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
/** Contextual navigation occupies a full sidebar even if the saved global nav mode is collapsed. */
|
|
forceExpanded?: boolean;
|
|
/** Optional local shadow preview; the shared token remains the default. */
|
|
shadow?: CSSProperties["boxShadow"];
|
|
}
|
|
|
|
interface MenuActionProps {
|
|
label: string;
|
|
icon: LucideIcon;
|
|
onClick?: () => void;
|
|
href?: string;
|
|
/** Opens `href` in a new tab (docs and other off-product links). */
|
|
external?: boolean;
|
|
/**
|
|
* Leaves the app in the current tab with a full navigation. Cloud links
|
|
* must use this: the cloud harness shadows those paths on tenant hosts, so
|
|
* the in-app router can never reach them.
|
|
*/
|
|
topLevel?: 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 MenuAction({
|
|
label,
|
|
icon: Icon,
|
|
onClick,
|
|
href,
|
|
external = false,
|
|
topLevel = false,
|
|
}: MenuActionProps) {
|
|
const className =
|
|
"flex h-(--profile-popover-row-height) w-full items-center gap-(--profile-popover-row-gap) rounded-lg px-2.5 text-left text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) text-foreground transition-colors hover:bg-accent";
|
|
|
|
const content = (
|
|
<>
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
|
<Icon className="size-4" />
|
|
</span>
|
|
<TruncatedText className="min-w-0 flex-1 truncate">{label}</TruncatedText>
|
|
</>
|
|
);
|
|
|
|
if (href) {
|
|
if (external) {
|
|
return (
|
|
<a href={href} target="_blank" rel="noreferrer" className={className} onClick={onClick}>
|
|
{content}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
if (topLevel) {
|
|
return (
|
|
<a href={href} 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,
|
|
forceExpanded = false,
|
|
shadow,
|
|
}: SidebarAccountMenuProps) {
|
|
const cloud = useCloudInstance();
|
|
const isCloud = Boolean(cloud);
|
|
// Invites live on the Members page (or in Cloud's People settings). Hide the
|
|
// shortcut when the hosting operator hides either surface, and until the
|
|
// health response resolves so a hidden surface never flashes.
|
|
const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings();
|
|
// On Cloud the shortcut exists only for the current stack's owner/admin and
|
|
// only once the stack metadata is known; the in-app Invites tab is never a
|
|
// fallback there because it drives a different invitation flow.
|
|
const cloudInviteUrl = useCloudInviteUrl();
|
|
// Self-hosted invites need the `users:invite` grant. Offer the shortcut only
|
|
// to boards with role-default access (company owner/admin/operator,
|
|
// instance admins, local boards). The server checks the actual grants.
|
|
const canInviteMembers = useCanInviteCompanyMembers(!isCloud);
|
|
const inviteHref = isCloud ? cloudInviteUrl : canInviteMembers ? INVITES_PATH : null;
|
|
const showInvite =
|
|
hiddenSettingsLoaded &&
|
|
inviteHref !== null &&
|
|
!hidesCompanyPage(hiddenSettings, "company.members") &&
|
|
!hidesCompanyPage(hiddenSettings, "company.invites");
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
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,
|
|
queryFn: () => authApi.getSession(),
|
|
retry: false,
|
|
});
|
|
|
|
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
|
|
|
|
const displayName = session?.user.name?.trim() || "Board";
|
|
const secondaryLabel =
|
|
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
|
|
const initials = deriveInitials(displayName);
|
|
const profileHref = userProfilePath(session?.user);
|
|
|
|
function closeNavigationChrome() {
|
|
setOpen(false);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}
|
|
|
|
function handleSignOut() {
|
|
signOutMutation.mutate();
|
|
}
|
|
|
|
return (
|
|
<div data-sidebar-account="" className="bg-border/50 px-3 py-2 dark:bg-muted">
|
|
<div className={cn("flex items-center gap-0.5", !rail && "px-2")}>
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex min-w-0 items-center gap-2.5 rounded-lg text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
rail ? "w-full px-3 py-2" : "flex-1 px-2 py-1.5",
|
|
)}
|
|
aria-label="Open account menu"
|
|
>
|
|
<Avatar size="sm">
|
|
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
|
<AvatarFallback>{initials}</AvatarFallback>
|
|
</Avatar>
|
|
<TruncatedText className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</TruncatedText>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<FlyoutContent
|
|
shadow={shadow}
|
|
aria-label="Account"
|
|
side="top"
|
|
align="start"
|
|
sideOffset={10}
|
|
className="w-(--profile-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
|
|
>
|
|
{/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */}
|
|
<FlyoutHeader data-flyout-profile data-marquee-surface className="relative flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
|
|
<Link
|
|
to={profileHref}
|
|
aria-label="View profile"
|
|
onClick={closeNavigationChrome}
|
|
className="absolute inset-0 transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
/>
|
|
<Avatar className="pointer-events-none relative size-9">
|
|
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
|
<AvatarFallback className="text-xs text-foreground">{initials}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="pointer-events-none relative min-w-0 flex-1">
|
|
<TruncatedText as="h2" className="truncate text-sm font-semibold leading-(--profile-popover-label-line-height) text-foreground">
|
|
{displayName}
|
|
</TruncatedText>
|
|
<TruncatedText as="p" className="truncate text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground">
|
|
{secondaryLabel}
|
|
</TruncatedText>
|
|
{stagingCommit ? (
|
|
<a
|
|
className="pointer-events-auto 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>
|
|
</FlyoutHeader>
|
|
|
|
<FlyoutOptions className="flex flex-col gap-0.5 border-t border-border p-2.5">
|
|
<MenuAction
|
|
label="Settings"
|
|
icon={Settings}
|
|
href="/company/settings"
|
|
onClick={closeNavigationChrome}
|
|
/>
|
|
{showInvite && inviteHref ? (
|
|
<MenuAction
|
|
label="Invite"
|
|
icon={UserPlus}
|
|
href={inviteHref}
|
|
topLevel={isCloud}
|
|
onClick={closeNavigationChrome}
|
|
/>
|
|
) : null}
|
|
<MenuAction
|
|
label="Documentation"
|
|
icon={BookOpen}
|
|
href={DOCS_URL}
|
|
external
|
|
onClick={() => setOpen(false)}
|
|
/>
|
|
<ThemeToggle variant="compact-menu-action" onAfterToggle={() => setOpen(false)} />
|
|
{deploymentMode === "authenticated" ? (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex h-(--profile-popover-row-height) w-full items-center gap-(--profile-popover-row-gap) rounded-lg px-2.5 text-left text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) text-foreground transition-colors hover:bg-destructive/10",
|
|
signOutMutation.isPending && "cursor-not-allowed opacity-60",
|
|
)}
|
|
onClick={handleSignOut}
|
|
disabled={signOutMutation.isPending}
|
|
>
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
|
<LogOut className="size-4" />
|
|
</span>
|
|
<TruncatedText className="min-w-0 flex-1 truncate">
|
|
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
|
</TruncatedText>
|
|
</button>
|
|
) : null}
|
|
<SidebarServerInfo />
|
|
</FlyoutOptions>
|
|
</FlyoutContent>
|
|
</Popover>
|
|
{!rail && !isCloud ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<a
|
|
href={FEEDBACK_URL}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
aria-label="Share feedback"
|
|
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground/50 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Flag className="h-4 w-4" aria-hidden="true" />
|
|
</a>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">Share feedback</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|