mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The web UI has a sidebar account menu that opens from the user's name in the lower left > - The menu header shows an "Account"/"Local" badge and a "Paperclip <sha>" (or "Paperclip v<version>") build line next to the user's identity > - These labels add noise to the header and repeat information that is available elsewhere: the email line already shows the sign-in state, and the opt-in "Server" debug section in the same menu shows the running commit > - This pull request removes the badge and the build line so the header shows only the user's name and email > - The benefit is a cleaner account menu that shows only identity information ## Linked Issues or Issue Description No existing issue. Related: #9637 (closed) added the source-sha rendering that this PR removes from the menu header. Description follows the enhancement template: **What existing behavior does this improve?** The sidebar account menu popover. Its header shows the user's name, an "Account" or "Local" badge, the email, and a build identifier line ("Paperclip <short sha>" with branch/commit links for source builds, or "Paperclip v<version>" for release builds). **Current behavior** The popover header mixes identity information with deployment and build metadata. The badge and the version line take space and do not help daily use. **Proposed behavior** The popover header shows only the user's name and email. Build information stays available in the "Server" section at the bottom of the same menu when the server-info debug view is enabled in experimental instance settings. **Reason and benefit** Less visual noise in a menu that users open often. No information is lost: sign-in state is clear from the email line, and the running commit remains visible through the server-info debug view. **Breaking changes** None. The `SidebarAccountMenu` components no longer accept the `serverGit` and `version` props; both call sites in the two `Layout` variants are updated in this PR. ## What Changed - `ui/src/components/SidebarAccountMenu.tsx` and `SidebarAccountMenu.production.tsx`: remove the "Account"/"Local" badge and the full version block (source-build branch/commit links and the release-version fallback); drop the now-unused `serverGit`/`version` props, the sha-parsing helper, and the `Badge` import - `ui/src/components/Layout.tsx` and `Layout.production.tsx`: stop passing the removed props at all four call sites - `ui/src/components/SidebarAccountMenu.test.tsx`: delete the source-build sha test; the sign-out test now pins that the popover contains neither "Account" nor "Paperclip v" - `ui/storybook/stories/navigation-layout.stories.tsx`: stop passing the removed `version` prop in the account-menu story ## Verification - `pnpm vitest run ui/src/components/SidebarAccountMenu.test.tsx ui/src/components/Layout.test.tsx` — 36 tests pass - `tsc --noEmit` for the `ui` package passes - Manual: open the app, click your name in the lower left. The popover header shows only name and email. ## Risks - Low risk. UI-only removal with no data or API changes. - Users who relied on the header sha to identify a source build must enable the experimental server-info debug view to see the running commit in the same menu. > 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 - Claude (Anthropic), Claude Fable 5, model ID `claude-fable-5`, extended thinking enabled, via Claude Code CLI with tool use (file edit, shell, test runner) ## 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
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
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";
|
|
import { SidebarAccountMenu as ProductionSidebarAccountMenu } from "./SidebarAccountMenu.production";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
|
|
const mockAuthApi = vi.hoisted(() => ({
|
|
getSession: vi.fn(),
|
|
signInEmail: vi.fn(),
|
|
signUpEmail: vi.fn(),
|
|
getProfile: vi.fn(),
|
|
updateProfile: vi.fn(),
|
|
signOut: vi.fn(),
|
|
}));
|
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|
getExperimental: vi.fn(),
|
|
}));
|
|
const mockToggleTheme = vi.hoisted(() => vi.fn());
|
|
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
|
const mockNavigateTopLevel = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/api/auth", () => ({
|
|
authApi: mockAuthApi,
|
|
}));
|
|
|
|
vi.mock("@/lib/browserNavigation", () => ({
|
|
navigateTopLevel: mockNavigateTopLevel,
|
|
}));
|
|
|
|
vi.mock("@/api/instanceSettings", () => ({
|
|
instanceSettingsApi: mockInstanceSettingsApi,
|
|
}));
|
|
|
|
vi.mock("../api/instanceSettings", () => ({
|
|
instanceSettingsApi: mockInstanceSettingsApi,
|
|
}));
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
|
|
<a href={to} {...props}>{children}</a>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../context/SidebarContext", () => ({
|
|
useSidebar: () => ({
|
|
isMobile: false,
|
|
setSidebarOpen: mockSetSidebarOpen,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../context/ThemeContext", () => ({
|
|
useTheme: () => ({
|
|
theme: "dark",
|
|
toggleTheme: mockToggleTheme,
|
|
}),
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
await callback();
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
|
|
async function flushReact() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
describe("SidebarAccountMenu", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
mockAuthApi.getSession.mockResolvedValue({
|
|
session: { id: "session-1", userId: "user-1" },
|
|
user: {
|
|
id: "user-1",
|
|
name: "Jane Example",
|
|
email: "jane@example.com",
|
|
image: "https://example.com/jane.png",
|
|
},
|
|
});
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableIsolatedWorkspaces: false,
|
|
});
|
|
mockAuthApi.signOut.mockResolvedValue({ success: true, redirectTo: "/cloud/logout" });
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("shares the nav background without separator borders", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<TooltipProvider>
|
|
<SidebarAccountMenu deploymentMode="local_trusted" />
|
|
</TooltipProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
|
|
const accountSurface = container.firstElementChild;
|
|
expect(accountSurface?.className).toContain("bg-border/50");
|
|
expect(accountSurface?.className).toContain("dark:bg-muted");
|
|
expect(accountSurface?.className).not.toContain("border-t");
|
|
expect(accountSurface?.className).not.toContain("border-r");
|
|
expect(accountSurface?.className).not.toContain("border-border");
|
|
const accountTrigger = container.querySelector('button[aria-label="Open account menu"]');
|
|
expect(accountTrigger?.classList).toContain("rounded-lg");
|
|
expect(accountTrigger?.classList).toContain("hover:bg-background");
|
|
|
|
const feedbackButton = container.querySelector<HTMLAnchorElement>(
|
|
'a[aria-label="Share feedback"]',
|
|
);
|
|
expect(feedbackButton?.getAttribute("href")).toBe("https://paperclip.ing/feedback");
|
|
expect(feedbackButton?.getAttribute("target")).toBe("_blank");
|
|
expect(feedbackButton?.classList).toContain("hover:bg-background");
|
|
expect(feedbackButton?.querySelector("svg")?.classList).toContain("lucide-flag");
|
|
expect(feedbackButton?.getAttribute("data-slot")).toBe("tooltip-trigger");
|
|
expect(feedbackButton?.hasAttribute("title")).toBe(false);
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("keeps the classic feedback control visible beside the profile trigger", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<TooltipProvider>
|
|
<ProductionSidebarAccountMenu deploymentMode="local_trusted" />
|
|
</TooltipProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
|
|
const accountTrigger = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Open account menu"]',
|
|
);
|
|
expect(accountTrigger?.classList).toContain("rounded-lg");
|
|
expect(accountTrigger?.classList).toContain("hover:bg-accent/50");
|
|
|
|
const feedbackButton = container.querySelector<HTMLAnchorElement>(
|
|
'a[aria-label="Share feedback"]',
|
|
);
|
|
expect(feedbackButton?.getAttribute("href")).toBe("https://paperclip.ing/feedback");
|
|
expect(feedbackButton?.getAttribute("target")).toBe("_blank");
|
|
expect(feedbackButton?.classList).toContain("hover:bg-accent/50");
|
|
expect(feedbackButton?.querySelector("svg")?.classList).toContain("lucide-flag");
|
|
expect(feedbackButton?.getAttribute("data-slot")).toBe("tooltip-trigger");
|
|
|
|
await act(async () => {
|
|
accountTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
const popover = document.body.querySelector('[data-slot="popover-content"]');
|
|
expect(popover?.textContent).not.toContain("Feedback");
|
|
expect(popover?.querySelector('a[href="https://paperclip.ing/feedback"]')).toBeNull();
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("keeps authenticated self-hosted sign-out on the local auth flow", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
queryClient.setQueryData(queryKeys.health, {
|
|
status: "ok",
|
|
deploymentMode: "authenticated",
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<TooltipProvider>
|
|
<SidebarAccountMenu deploymentMode="authenticated" />
|
|
</TooltipProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Jane Example");
|
|
expect(container.textContent).not.toContain("jane@example.com");
|
|
|
|
const trigger = container.querySelector('button[aria-label="Open account menu"]');
|
|
expect(trigger).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("Edit profile");
|
|
expect(document.body.textContent).toContain("Settings");
|
|
expect(document.body.textContent).not.toContain("Instance settings");
|
|
expect(document.body.textContent).toContain("Documentation");
|
|
|
|
const popover = document.body.querySelector('[data-slot="popover-content"]');
|
|
expect(popover?.textContent).not.toContain("Feedback");
|
|
expect(popover?.querySelector('a[href="https://paperclip.ing/feedback"]')).toBeNull();
|
|
|
|
// Documentation still appears before the theme toggle.
|
|
const menuText = popover?.textContent ?? "";
|
|
const docsPos = menuText.indexOf("Documentation");
|
|
const themePos = menuText.indexOf("Switch to");
|
|
expect(docsPos).toBeLessThan(themePos);
|
|
|
|
// The popover header stays down to name + email: no "Account" badge, no version line.
|
|
expect(popover?.textContent).not.toContain("Account");
|
|
expect(popover?.textContent).not.toContain("Paperclip v");
|
|
expect(document.body.textContent).toContain("jane@example.com");
|
|
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
|
|
.toContain("w-(--sz-277px)");
|
|
expect(document.body.querySelector('a[href="/company/settings/instance/profile"]')).not.toBeNull();
|
|
expect(document.body.querySelector('a[href="/company/settings"]')).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(mockNavigateTopLevel).not.toHaveBeenCalled();
|
|
expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("navigates cloud-managed sign-out through the harness without calling local auth", async () => {
|
|
const root = createRoot(container);
|
|
const onOpenChange = vi.fn();
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
queryClient.setQueryData(queryKeys.health, {
|
|
status: "ok",
|
|
deploymentMode: "authenticated",
|
|
cloud: {
|
|
managed: true,
|
|
managedBy: "paperclip-cloud",
|
|
stackSlug: "acme-labs",
|
|
cloudBaseUrl: "https://cloud.example.test",
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<TooltipProvider>
|
|
<SidebarAccountMenu
|
|
deploymentMode="authenticated"
|
|
open
|
|
onOpenChange={onOpenChange}
|
|
/>
|
|
</TooltipProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
|
|
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).not.toHaveBeenCalled();
|
|
expect(mockNavigateTopLevel).toHaveBeenCalledOnce();
|
|
expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout");
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps sign-out hidden outside authenticated deployment mode", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<TooltipProvider>
|
|
<SidebarAccountMenu deploymentMode="local_trusted" open />
|
|
</TooltipProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).not.toContain("Sign out");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
});
|