Files
PaperClipAI/ui/src/components/SidebarAccountMenu.tsx
T
Michael Nguyen fe38085174 fix(ui): hide profile feedback flag on Cloud (#13292)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The sidebar provides account controls and a feedback link.
> - Cloud now has Plain chat through the snippet support in #13168.
> - The feedback flag creates a second feedback route on Cloud.
> - This PR uses the existing Cloud metadata to hide the flag.
> - Self-hosted users keep the existing feedback link.

## Linked Issues or Issue Description

Refs #13168 and #10850. Related: #12778 proposes a separate native
feedback relay; this PR only hides the existing flag on Cloud.

**What happened?**
Cloud users see both Plain chat and the profile feedback flag, which
opens the external feedback form.

**Expected behavior**
Cloud users use Plain chat. Self-hosted users keep the profile feedback
flag.

**Steps to reproduce**
Open a Cloud instance with Plain enabled and expand the sidebar. Both
feedback controls are visible.

## What Changed

- Use `useCloudInstance()` in both account-menu variants.
- Render the feedback flag only when the sidebar is expanded and the
instance is not Cloud.
- Extend existing tests for Cloud and authenticated self-hosted
behavior.

## Verification

- Account-menu tests: 6 passed (`cd ui && pnpm exec vitest run
src/components/SidebarAccountMenu.test.tsx`).
- `pnpm check:token-gates`: passed.
- `pnpm --filter @paperclipai/ui typecheck`: passed.
- Full typecheck: failed in unchanged `plugin-workspace-diff` code
(`editStateKey` required by `UseFileDiffInstanceProps`).
- UI build: blocked by four missing exports in the installed
`assistant-cloud` dependency, including `createRunReport`.
- Repository build: failed on the same unchanged workspace-diff plugin
types.
- Full test suite: started, then stopped after broader validation hit
dependency failures. Only the six targeted account-menu tests are
claimed as passed.
- Fresh installation used `--no-frozen-lockfile --lockfile=false`
because the base lockfile and server manifest differ. No dependency or
lockfile changes are included.
- After deployment, check that Cloud has no profile feedback flag and
self-hosted still links to `https://paperclip.ing/feedback`.

## Risks

Low risk. The hook reads the existing health query cache and adds no
request. CloudAccessGate loads health before the main UI mounts. The
flag is hidden on all Cloud instances, so operators must keep Plain
configured. No backend, settings, or Plain integration changes.

## Model Used

OpenAI GPT-6 Codex, with code inspection and command execution. The
exact model ID and context-window size are not exposed in this session.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-09-11 17:03:38 -07:00

255 lines
9.4 KiB
TypeScript

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
Flag,
LogOut,
Settings,
type LucideIcon,
UserRound,
UserRoundPen,
} from "lucide-react";
import 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 { useSignOut } from "@/hooks/useSignOut";
import { useSidebar } from "../context/SidebarContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
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";
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
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;
}
interface MenuActionProps {
label: 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 MenuAction({
label,
icon: Icon,
onClick,
href,
external = 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>
<span className="min-w-0 flex-1 truncate">{label}</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,
forceExpanded = false,
}: SidebarAccountMenuProps) {
const isCloud = Boolean(useCloudInstance());
const [internalOpen, setInternalOpen] = useState(false);
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking && !forceExpanded;
const open = controlledOpen ?? internalOpen;
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 = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
function closeNavigationChrome() {
setOpen(false);
if (isMobile) setSidebarOpen(false);
}
function handleSignOut() {
signOutMutation.mutate();
}
return (
<div 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>
<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="min-h-(--profile-popover-min-height) w-(--profile-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
>
<div className="flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
<Avatar className="size-9">
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
<AvatarFallback className="text-xs text-foreground">{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold leading-(--profile-popover-label-line-height) text-foreground">
{displayName}
</h2>
<p className="truncate text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground">
{secondaryLabel}
</p>
</div>
</div>
<div className="flex flex-1 flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
<MenuAction
label="Settings"
icon={Settings}
href="/company/settings"
onClick={closeNavigationChrome}
/>
<MenuAction
label="View profile"
icon={UserRound}
href={profileHref}
onClick={closeNavigationChrome}
/>
<MenuAction
label="Edit profile"
icon={UserRoundPen}
href={PROFILE_SETTINGS_PATH}
onClick={closeNavigationChrome}
/>
<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>
<span className="min-w-0 flex-1 truncate">
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
</span>
</button>
) : null}
<SidebarServerInfo />
</div>
</PopoverContent>
</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>
);
}