mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## 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
255 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|