fix(ui): polish core navigation and task layout (#12793)

<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use the main navigation, contextual navigation, and task
chat throughout the product.
> - The recent core UI refactor left uneven spacing and inconsistent
navigation styles.
> - The Apps label also did not match the Connectors product language.
> - The account area did not provide a clear direct path for feedback.
> - This pull request aligns these related core UI surfaces and
preserves their existing behavior.
> - The benefit is a more consistent interface with clearer navigation
and balanced task-chat layout.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

This improves the core sidebar, Settings navigation, Connectors catalog,
task-chat layout, and account controls.

**Subsystem affected**

`ui/` — React and Vite board UI.

**Current behavior**

The task chat had uneven edge treatment. Settings used a separate
contextual-navigation style. Apps used inconsistent product labels. The
account footer did not expose a direct feedback control.

**Proposed behavior**

The task chat keeps balanced content padding while its scrollbar sits at
the properties boundary. Settings replaces the primary sidebar with a
matching navigation surface and a Back to app link. Apps uses Connectors
and Browse labels. The account footer provides a dedicated feedback icon
with a tooltip.

**Reason and benefit**

These changes make related navigation and layout patterns predictable.
They reduce duplicate labels and improve access to feedback.

**Breaking changes**

None. Routes, APIs, and stored data do not change.

## What Changed

- Balanced the task-chat content gutter and moved its scrollbar to the
properties-panel boundary.
- Reworked Settings navigation to replace the main sidebar and use the
shared primary-sidebar style.
- Added a Back to app navigation item to Settings.
- Renamed Apps to Connectors in the main navigation and added the
`Unplug` icon.
- Renamed the Connectors contextual item to Browse.
- Added the Connectors top-level header and aligned the search field
with the connector cards.
- Added account-footer hover states and a direct feedback flag with a
Share feedback tooltip.
- Removed the duplicate Feedback item from the account popover.
- Added regression coverage for each changed UI surface.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/AppsSidebar.test.tsx
src/components/CompanySettingsSidebar.test.tsx
src/components/Layout.test.tsx src/components/Sidebar.test.tsx
src/components/SidebarAccountMenu.test.tsx
src/components/task-chat/TaskMessageScroller.test.tsx
src/pages/apps/Browse.test.tsx` — 90 tests passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm --filter @paperclipai/ui build` — passed.
- `pnpm check:token-gates` — passed.
- `git diff --check origin/master...HEAD` — passed.
- `env PAPERCLIP_PLAYWRIGHT_CHANNEL=chrome PAPERCLIP_E2E_PORT=3201 pnpm
exec playwright test --config tests/e2e/playwright.config.ts
tests/e2e/apps-dark-mode-shots.spec.ts
tests/e2e/sidebar-takeover.spec.ts` — 10 tests passed.
- The full workspace typecheck and build reached the Rust runner and
stopped because `cargo` is not installed on this machine.
- The full test suite exposed unrelated server and workspace-runtime
failures and was stopped after the affected suites completed. No changed
UI test failed.
- Manually verified the changed Settings, Connectors, task-chat, and
account-menu surfaces in the running app.

## Risks

- Low risk. The change affects layout and navigation presentation only.
- The Settings sidebar now replaces the main sidebar by design. Users
must use Back to app to return to the application navigation.
- The task scrollbar offset depends on the existing responsive page
gutters. Regression tests cover both narrow and desktop spacing.

> 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, `gpt-5.6-sol`, extended reasoning with tool use and code
execution. The host does not expose the context-window size.

## 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
- [ ] 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: Scott Tong <scott@scottsmbpm5max.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
scotttong 2026-09-03 15:35:39 -07:00 committed by GitHub
parent fa86407ad8
commit 871f7d1124
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 452 additions and 249 deletions

View File

@ -115,12 +115,12 @@ test.describe.serial("dark-mode Apps surfaces", () => {
await healthy?.close();
});
test("sidebar says Apps and links to /apps", async ({ page }) => {
test("sidebar says Connectors and links to /apps", async ({ page }) => {
await forceDark(page);
await page.goto(`/${seed.prefix}/dashboard`);
const appsLink = page.getByRole("link", { name: "Apps", exact: true });
await expect(appsLink).toBeVisible({ timeout: 30_000 });
await expect(appsLink).toHaveAttribute("href", new RegExp(`/${seed.prefix}/apps$`));
const connectorsLink = page.getByRole("link", { name: "Connectors", exact: true });
await expect(connectorsLink).toBeVisible({ timeout: 30_000 });
await expect(connectorsLink).toHaveAttribute("href", new RegExp(`/${seed.prefix}/apps$`));
});
test("apps list dark mode with attention banner", async ({ page }) => {

View File

@ -3,9 +3,9 @@ import { test, expect, request as pwRequest, type APIRequestContext } from "@pla
/**
* E2E: contextual sidebar companion model.
*
* Contextual routes render their navigation beside the stable global sidebar.
* The global company navigation and account menu remain available, and leaving
* the surface restores the user's global sidebar preference.
* Most contextual routes render their navigation beside the stable global
* sidebar. Settings intentionally takes over that sidebar while preserving the
* account menu, and its Back to app link restores the global navigation.
*
* Plugin route sidebars share the same Layout path. A live plugin-route test
* requires a plugin fixture, so that branch remains covered by Layout tests.
@ -60,7 +60,7 @@ test.describe("Contextual sidebar companion", () => {
}, COLLAPSED_STORAGE_KEY);
});
test("shows Settings beside the global navigation", async ({ page }) => {
test("replaces global navigation with Settings navigation", async ({ page }) => {
await page.goto(`/${prefix}/company/settings`);
const contextual = page.locator('[data-contextual-sidebar="settings"]');
@ -70,10 +70,10 @@ test.describe("Contextual sidebar companion", () => {
await expect(contextual.getByRole("link", { name: "General" })).toBeVisible();
await expect(contextual.getByText("Environments", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Back from Settings" })).toBeVisible();
await expect(contextual.getByRole("link", { name: "Back to app" })).toBeVisible();
await expect(page.getByRole("button", { name: "Open account menu" })).toBeVisible();
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
await expect(page.getByRole("link", { name: "Dashboard" })).toHaveCount(0);
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
});
@ -91,7 +91,7 @@ test.describe("Contextual sidebar companion", () => {
test("keeps the retired collapse control absent across contextual navigation", async ({ page }) => {
await page.goto(`/${prefix}/company/settings`);
await expect(page.locator('[data-contextual-sidebar="settings"]')).toBeVisible();
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
await expect(page.getByRole("link", { name: "Back to app" })).toBeVisible();
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
await page.goto(`/${prefix}/dashboard`);
@ -101,9 +101,9 @@ test.describe("Contextual sidebar companion", () => {
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
});
test("uses Dashboard as the safe fallback for a direct Settings link", async ({ page }) => {
test("uses Dashboard as the destination for a direct Settings link", async ({ page }) => {
await page.goto(`/${prefix}/company/settings`);
await page.getByRole("button", { name: "Back from Settings" }).click();
await page.getByRole("link", { name: "Back to app" }).click();
await expect(page).toHaveURL(new RegExp(`/${prefix}/dashboard$`));
await expect(page.locator("[data-contextual-sidebar]")).toHaveCount(0);

View File

@ -8,10 +8,10 @@ import { useReviewCount } from "@/pages/apps/useReviewCount";
import { SidebarNavItem } from "./SidebarNavItem.production";
/**
* Secondary sidebar for the prosumer Apps area (PAP-10856; three-door IA
* Secondary sidebar for the prosumer Connectors area (PAP-10856; three-door IA
* PAP-13254 / U3).
*
* Back · APPS: Browse / Review (n)
* Back · CONNECTORS: Browse / Review (n)
* DEVELOPER: Connections / Gateways / Profiles / Rules / Health / Activity
*
* "Browse" is the store and "Review" holds decisions waiting on the user's
@ -19,7 +19,7 @@ import { SidebarNavItem } from "./SidebarNavItem.production";
* "Needs attention" is no longer a door: health/error triage folds into
* Connections as a status filter + banner, so approvals are never buried
* behind an error label. The Developer section was folded in from the retired
* ToolsSidebar (PAP-10915) so the whole Apps area shares one sidebar; a
* ToolsSidebar (PAP-10915) so the whole Connectors area shares one sidebar; a
* one-line caption frames who it's for (Finding A). "Run your own" and "Paste a
* config" moved out of the sidebar into rows on the Connect-an-app page
* (PAP-10922).
@ -49,13 +49,13 @@ export function AppsSidebar() {
</Link>
<div className="flex items-center gap-2 px-2 py-1">
<AppWindow className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 truncate text-sm font-bold text-foreground">Apps</span>
<span className="flex-1 truncate text-sm font-bold text-foreground">Connectors</span>
</div>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="px-3 pb-1 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Apps
Connectors
</div>
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/apps" label="Browse" icon={Store} end />

View File

@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppsSidebar } from "./AppsSidebar";
import { AppsSidebar as ProductionAppsSidebar } from "./AppsSidebar.production";
import { contextualSidebarStyles } from "./contextual-sidebar-styles";
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
@ -61,6 +62,19 @@ vi.mock("./SidebarNavItem", () => ({
},
}));
vi.mock("./SidebarNavItem.production", () => ({
SidebarNavItem: (props: {
to: string;
label: string;
end?: boolean;
liveCount?: number;
badge?: number;
}) => {
sidebarNavItemMock(props);
return <div data-to={props.to}>{props.label}</div>;
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@ -114,6 +128,7 @@ describe("AppsSidebar", () => {
await flushReact();
expect(container.textContent).not.toContain("Apps");
expect(container.querySelector('nav[aria-label="Connectors"]')).not.toBeNull();
expect(container.textContent).not.toContain("Developer");
expect(container.textContent).not.toContain("Advanced setup for developers");
expect(container.textContent).not.toContain("Most teams");
@ -122,7 +137,7 @@ describe("AppsSidebar", () => {
// assert both advanced setup items remain absent at the item level below.
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps", label: "Connectors", end: true }),
expect.objectContaining({ to: "/apps", label: "Browse", end: true }),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/review", label: "Review" }),
@ -166,4 +181,30 @@ describe("AppsSidebar", () => {
root.unmount();
});
});
it("uses Connectors terminology throughout the classic contextual sidebar", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ProductionAppsSidebar />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Connectors");
expect(container.textContent).not.toContain("Apps");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps", label: "Browse", end: true }),
);
await act(async () => {
root.unmount();
});
});
});

View File

@ -25,12 +25,12 @@ export function AppsSidebar() {
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<nav
aria-label="Apps"
aria-label="Connectors"
data-slot="contextual-sidebar-nav"
className={contextualSidebarStyles.nav}
>
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
<SidebarNavItem to="/apps" label="Connectors" icon={Store} end />
<SidebarNavItem to="/apps" label="Browse" icon={Store} end />
<SidebarNavItem
to="/apps/review"
label="Review"

View File

@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
import { primarySidebarStyles } from "./primary-sidebar-styles";
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockSidebarBadgesApi = vi.hoisted(() => ({
@ -124,7 +125,7 @@ describe("CompanySettingsSidebar", () => {
vi.clearAllMocks();
});
it("renders one unified settings list without company or instance headers", async () => {
it("renders a primary-style settings takeover with a back-to-app link", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@ -139,7 +140,31 @@ describe("CompanySettingsSidebar", () => {
});
await flushReact();
expect(container.textContent).toContain("Paperclip");
expect(container.textContent).not.toContain("Paperclip");
expect(container.textContent).not.toContain("Settings");
expect(container.querySelector('[aria-label="Back from Settings"]')).toBeNull();
const settingsSurface = container.querySelector('[data-contextual-sidebar="settings"]');
expect(settingsSurface?.classList).toContain("bg-border/50");
expect(settingsSurface?.classList).toContain("dark:bg-muted");
expect(container.querySelector('[data-slot="contextual-sidebar-nav"]')?.className).toBe(
primarySidebarStyles.nav,
);
const settingsHeader = container.querySelector('[data-slot="settings-sidebar-header"]');
expect(settingsHeader?.classList).toContain("h-(--sz-60px)");
expect(settingsHeader?.classList).toContain("items-center");
expect(settingsHeader?.textContent).toContain("Back to app");
const backGroup = container.querySelector('[data-slot="settings-back-group"]');
expect(backGroup?.classList).toContain("w-full");
for (const className of primarySidebarStyles.group.split(" ")) {
expect(backGroup?.classList).toContain(className);
}
expect(container.querySelector('[data-slot="contextual-sidebar-group"]')?.className).toBe(
primarySidebarStyles.group,
);
expect(container.textContent).toContain("Back to app");
expect(container.querySelector('nav[aria-label="Settings"]')?.textContent).not.toContain(
"Back to app",
);
expect(container.textContent).not.toContain("Company Settings");
expect(container.textContent).not.toContain("Instance Settings");
expect(container.textContent).toContain("General");
@ -150,6 +175,12 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("Secrets");
expect(container.textContent).toContain("Access");
expect(container.textContent).not.toContain("Tools & Access");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/dashboard",
label: "Back to app",
}),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings",

View File

@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
Cpu,
Download,
FlaskConical,
@ -26,6 +27,7 @@ import { useHiddenSettings } from "@/hooks/useHiddenSettings";
import { usePluginSlots } from "@/plugins/slots";
import { SidebarNavItem } from "./SidebarNavItem";
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
import { primarySidebarStyles } from "./primary-sidebar-styles";
/**
* Sandbox-provider-only plugins (e.g. E2B, exe.dev, Modal) have no per-plugin
@ -80,9 +82,26 @@ export function CompanySettingsSidebar() {
const sidebarPlugins = (plugins ?? []).filter((plugin) => !isSandboxProviderOnly(plugin));
return (
<ContextualSidebarFrame surface="settings" title="Settings" icon={SlidersHorizontal}>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="flex flex-col gap-0.5">
<ContextualSidebarFrame
surface="settings"
title="Settings"
showHeader={false}
className={primarySidebarStyles.surface}
>
<div
data-slot="settings-sidebar-header"
className="flex h-(--sz-60px) shrink-0 items-center px-3"
>
<div data-slot="settings-back-group" className={`${primarySidebarStyles.group} w-full`}>
<SidebarNavItem to="/dashboard" label="Back to app" icon={ArrowLeft} />
</div>
</div>
<nav
aria-label="Settings"
data-slot="contextual-sidebar-nav"
className={primarySidebarStyles.nav}
>
<div data-slot="contextual-sidebar-group" className={primarySidebarStyles.group}>
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
{showPage("instance.profile") && (
<SidebarNavItem

View File

@ -325,7 +325,7 @@ describe("Layout", () => {
});
});
it("scopes the Streamlined task-detail surface to the main pane and right sidebar row", async () => {
it("scopes the Streamlined task-detail surface while preserving balanced horizontal gutters", async () => {
currentPathname = "/PAP/issues/PAP-1";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableApps: true,
@ -347,8 +347,10 @@ describe("Layout", () => {
expect(container.querySelector(".streamlined-task-detail-surface")).not.toBeNull();
expect(container.querySelector("#main-content")?.classList.contains("pt-0")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("md:pt-0")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("pr-0")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("md:pr-0")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("p-4")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("md:p-6")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("pr-0")).toBe(false);
expect(container.querySelector("#main-content")?.classList.contains("md:pr-0")).toBe(false);
await act(async () => {
root.unmount();
@ -429,7 +431,7 @@ describe("Layout", () => {
await act(async () => { root.unmount(); });
});
it("keeps the app sidebar beside settings navigation on settings routes", async () => {
it("replaces the app sidebar with settings navigation on Streamlined settings routes", async () => {
currentPathname = "/PAP/company/settings/access";
mockPluginSlots.slots = [
{
@ -471,10 +473,10 @@ describe("Layout", () => {
await flushReact();
expect(container.textContent).toContain("Company settings sidebar");
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Main company nav");
const secondaryRail = container.querySelector("[data-secondary-sidebar]");
expect(secondaryRail?.classList.contains("w-60")).toBe(true);
expect(secondaryRail?.classList.contains("bg-background")).toBe(true);
expect(secondaryRail).not.toBeNull();
expect(secondaryRail?.classList.contains("w-60")).toBe(false);
expect(container.textContent).not.toContain("Company rail");
expect(container.textContent).not.toContain("Instance sidebar");
expect(container.textContent).not.toContain("Plugin route sidebar");
@ -553,7 +555,7 @@ describe("Layout", () => {
});
});
it("keeps the company nav beside settings on instance settings routes", async () => {
it("replaces the company nav on instance settings routes", async () => {
currentPathname = "/PAP/company/settings/instance/general";
const root = createRoot(container);
const queryClient = new QueryClient({
@ -571,7 +573,7 @@ describe("Layout", () => {
await flushReact();
expect(container.textContent).toContain("Company settings sidebar");
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Main company nav");
expect(container.textContent).not.toContain("Company rail");
expect(container.textContent).not.toContain("Plugin route sidebar");
@ -581,7 +583,7 @@ describe("Layout", () => {
});
it.each(["/PAP/company/export", "/PAP/company/import"])(
"keeps the company nav beside the shared settings sidebar on %s",
"replaces the company nav with the shared settings sidebar on %s",
async (pathname) => {
currentPathname = pathname;
const root = createRoot(container);
@ -600,7 +602,7 @@ describe("Layout", () => {
await flushReact();
expect(container.textContent).toContain("Company settings sidebar");
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Main company nav");
await act(async () => {
root.unmount();

View File

@ -209,8 +209,7 @@ export function Layout() {
) : sharedSecondarySidebar;
const hasSecondarySidebar = secondarySidebar != null;
const keepsPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && (
shellRoute.builtInContextualSurface === "settings"
|| shellRoute.builtInContextualSurface === "skills"
shellRoute.builtInContextualSurface === "skills"
|| shellRoute.builtInContextualSurface === "agent"
|| shellRoute.builtInContextualSurface === "routine"
|| isAppsRoute
@ -752,7 +751,7 @@ export function Layout() {
// The task thread owns its scrollable top spacing. Leaving the
// page shell's top padding in place creates a stationary dark
// strip below the breadcrumb while messages scroll behind it.
!isMobile && useStreamlinedTaskDetailShell && "pt-0 pr-0 md:pt-0 md:pr-0",
!isMobile && useStreamlinedTaskDetailShell && "pt-0 md:pt-0",
// Reserve the scrollbar gutter on desktop so pages whose height
// changes (e.g. switching skill-detail tabs) don't widen/shift
// when the vertical scrollbar appears or disappears (PAP-10907).

View File

@ -16,7 +16,7 @@ import {
Package,
Settings,
FolderOpen,
AppWindow,
Unplug,
MessagesSquare,
GanttChartSquare,
LayoutGrid,
@ -230,7 +230,7 @@ export function Sidebar() {
<SidebarSection label="Company" collapsible={{ open: companyOpen, onOpenChange: setCompanyOpen }}>
<SidebarNavItem to="/org" label="Org" icon={Network} />
{showApps ? <SidebarNavItem to="/apps" label="Apps" icon={AppWindow} /> : null}
{showApps ? <SidebarNavItem to="/apps" label="Connectors" icon={Unplug} /> : null}
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
{/* One entry — /audit merged into the rich Activity feed (PAP-16302). */}

View File

@ -300,7 +300,7 @@ describe("Sidebar", () => {
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined")).toBe("undefined");
expect(container.textContent).toContain("Organization");
expect(labels).toEqual(expect.arrayContaining(["Org", "Apps", "Timeline", "Costs", "Activity", "Settings"]));
expect(labels).toEqual(expect.arrayContaining(["Org", "Connectors", "Timeline", "Costs", "Activity", "Settings"]));
expect(labels).not.toContain("Audit");
expect(labels).not.toContain("Projects");
expect(container.querySelector('a[href="/agents"]')).toBeNull();
@ -406,7 +406,7 @@ describe("Sidebar", () => {
.map((anchor) => anchor.textContent?.trim());
expect(labels(workSection)).toEqual(["Tasks", "Projects", "Routines", "Artifacts"]);
expect(labels(orgSection)).toEqual(["Agents", "Skills", "Apps", "Audit"]);
expect(labels(orgSection)).toEqual(["Agents", "Skills", "Connectors", "Audit"]);
expect(sections.indexOf(workSection!)).toBeLessThan(sections.indexOf(orgSection!));
expect(
workSection?.querySelector('a[href="/issues"] svg')?.classList.contains("lucide-circle-check"),
@ -532,17 +532,18 @@ describe("Sidebar", () => {
});
});
it("always shows Apps in the Org section", async () => {
it("always shows Connectors in the Org section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
const root = await renderSidebar();
const links = [...container.querySelectorAll("a")];
const link = links.find((anchor) => anchor.textContent === "Apps");
const link = links.find((anchor) => anchor.textContent === "Connectors");
expect(link?.getAttribute("href")).toBe("/apps");
expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeGreaterThan(
expect(link?.querySelector("svg")?.classList).toContain("lucide-unplug");
expect(links.findIndex((anchor) => anchor.textContent === "Connectors")).toBeGreaterThan(
links.findIndex((anchor) => anchor.textContent === "Skills"),
);
expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeLessThan(
expect(links.findIndex((anchor) => anchor.textContent === "Connectors")).toBeLessThan(
links.findIndex((anchor) => anchor.textContent === "Audit"),
);

View File

@ -16,7 +16,7 @@ import {
Package,
Settings,
FolderOpen,
AppWindow,
Unplug,
MessagesSquare,
GanttChartSquare,
LayoutGrid,
@ -46,6 +46,7 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { PluginSlotOutlet } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
import { primarySidebarStyles } from "./primary-sidebar-styles";
export function Sidebar() {
const { openNewIssue } = useDialogActions();
@ -117,7 +118,7 @@ export function Sidebar() {
className={cn(
"w-full h-full min-h-0 flex flex-col",
streamlinedUiEnabled
? "bg-border/50 dark:bg-muted"
? primarySidebarStyles.surface
: "border-r border-border bg-background",
)}
>
@ -130,8 +131,8 @@ export function Sidebar() {
<SidebarCompanyMenu />
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">
<div className="flex flex-col gap-0.5">
<nav className={primarySidebarStyles.nav}>
<div className={primarySidebarStyles.group}>
{/* New Task button aligned with nav items */}
{(() => {
const newTaskButton = (
@ -239,7 +240,7 @@ export function Sidebar() {
>
<SidebarNavItem to="/agents" label="Agents" icon={Users} />
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
<SidebarNavItem to="/apps" label="Apps" icon={AppWindow} />
<SidebarNavItem to="/apps" label="Connectors" icon={Unplug} />
<SidebarNavItem to="/activity" label="Audit" icon={History} />
</SidebarSection>
) : null}
@ -255,7 +256,7 @@ export function Sidebar() {
collapsible={{ open: organizationOpen, onOpenChange: setOrganizationOpen }}
>
<SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/apps" label="Apps" icon={AppWindow} />
<SidebarNavItem to="/apps" label="Connectors" icon={Unplug} />
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />

View File

@ -2,8 +2,8 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
Flag,
LogOut,
Megaphone,
type LucideIcon,
UserRound,
UserRoundPen,
@ -15,6 +15,7 @@ import { queryKeys } from "@/lib/queryKeys";
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";
@ -154,11 +155,15 @@ export function SidebarAccountMenu({
return (
<div className="border-t border-r border-border bg-background px-3 py-2">
<Popover open={open} onOpenChange={setOpen}>
<div className={cn("flex items-center gap-0.5", !rail && "px-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"
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-accent/50 hover:text-foreground",
rail ? "w-full px-3 py-2" : "flex-1 px-2 py-1.5",
)}
aria-label="Open account menu"
>
<Avatar size="sm">
@ -244,14 +249,6 @@ export function SidebarAccountMenu({
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
@ -280,7 +277,24 @@ export function SidebarAccountMenu({
</div>
</div>
</PopoverContent>
</Popover>
</Popover>
{!rail ? (
<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-foreground/80 transition-colors hover:bg-accent/50 hover:text-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>
);
}

View File

@ -5,6 +5,8 @@ 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(),
@ -107,7 +109,9 @@ describe("SidebarAccountMenu", () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarAccountMenu deploymentMode="local_trusted" />
<TooltipProvider>
<SidebarAccountMenu deploymentMode="local_trusted" />
</TooltipProvider>
</QueryClientProvider>,
);
});
@ -119,6 +123,61 @@ describe("SidebarAccountMenu", () => {
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());
});
@ -136,10 +195,12 @@ describe("SidebarAccountMenu", () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarAccountMenu
deploymentMode="authenticated"
version="1.2.3"
/>
<TooltipProvider>
<SidebarAccountMenu
deploymentMode="authenticated"
version="1.2.3"
/>
</TooltipProvider>
</QueryClientProvider>,
);
});
@ -161,20 +222,16 @@ describe("SidebarAccountMenu", () => {
expect(document.body.textContent).toContain("Settings");
expect(document.body.textContent).not.toContain("Instance settings");
expect(document.body.textContent).toContain("Documentation");
expect(document.body.textContent).toContain("Feedback");
// Feedback link opens in a new tab pointing at the feedback URL
const feedbackAnchor = document.body.querySelector('a[href="https://paperclip.ing/feedback"]') as HTMLAnchorElement | null;
expect(feedbackAnchor).not.toBeNull();
expect(feedbackAnchor?.getAttribute("target")).toBe("_blank");
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();
// Feedback appears after Documentation and before the theme toggle
const menuText = document.body.querySelector('[data-slot="popover-content"]')?.textContent ?? "";
// Documentation still appears before the theme toggle.
const menuText = popover?.textContent ?? "";
const docsPos = menuText.indexOf("Documentation");
const feedbackPos = menuText.indexOf("Feedback");
const themePos = menuText.indexOf("Switch to");
expect(docsPos).toBeLessThan(feedbackPos);
expect(feedbackPos).toBeLessThan(themePos);
expect(docsPos).toBeLessThan(themePos);
expect(document.body.textContent).toContain("Paperclip v1.2.3");
expect(document.body.textContent).toContain("jane@example.com");
@ -220,11 +277,13 @@ describe("SidebarAccountMenu", () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarAccountMenu
deploymentMode="authenticated"
open
onOpenChange={onOpenChange}
/>
<TooltipProvider>
<SidebarAccountMenu
deploymentMode="authenticated"
open
onOpenChange={onOpenChange}
/>
</TooltipProvider>
</QueryClientProvider>,
);
});
@ -257,7 +316,9 @@ describe("SidebarAccountMenu", () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarAccountMenu deploymentMode="local_trusted" open />
<TooltipProvider>
<SidebarAccountMenu deploymentMode="local_trusted" open />
</TooltipProvider>
</QueryClientProvider>,
);
});
@ -279,26 +340,28 @@ describe("SidebarAccountMenu", () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarAccountMenu
deploymentMode="authenticated"
version="2026.626.0+58.git.518fc71ce"
serverGit={{
available: true,
fullSha: "518fc71ce1234567890abcdef1234567890abcde",
shortSha: "518fc71",
branchName: "feature/source-build-label",
subject: "Show source build label",
committedAt: "2026-06-26T00:00:00.000Z",
localChanges: {
<TooltipProvider>
<SidebarAccountMenu
deploymentMode="authenticated"
version="2026.626.0+58.git.518fc71ce"
serverGit={{
available: true,
hasLocalChanges: false,
stagedFileCount: 0,
unstagedFileCount: 0,
untrackedFileCount: 0,
},
}}
open
/>
fullSha: "518fc71ce1234567890abcdef1234567890abcde",
shortSha: "518fc71",
branchName: "feature/source-build-label",
subject: "Show source build label",
committedAt: "2026-06-26T00:00:00.000Z",
localChanges: {
available: true,
hasLocalChanges: false,
stagedFileCount: 0,
unstagedFileCount: 0,
untrackedFileCount: 0,
},
}}
open
/>
</TooltipProvider>
</QueryClientProvider>,
);
});

View File

@ -2,8 +2,8 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
Flag,
LogOut,
Megaphone,
Settings,
type LucideIcon,
UserRound,
@ -16,6 +16,7 @@ import { queryKeys } from "@/lib/queryKeys";
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";
@ -158,140 +159,153 @@ export function SidebarAccountMenu({
return (
<div className="bg-border/50 px-3 py-2 dark:bg-muted">
<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"
<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-background hover:text-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="w-(--sz-277px) max-w-(--sz-calc-24) overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
>
<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 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>
<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 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>
) : version ? (
<p className="mt-1 text-xs text-muted-foreground">Paperclip v{version}</p>
<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="Settings"
description="Manage company and instance settings."
icon={Settings}
href="/company/settings"
onClick={closeNavigationChrome}
/>
<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)}
/>
<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={handleSignOut}
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>
<div className="mt-4 space-y-1">
<MenuAction
label="Settings"
description="Manage company and instance settings."
icon={Settings}
href="/company/settings"
onClick={closeNavigationChrome}
/>
<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}
</PopoverContent>
</Popover>
{!rail ? (
<Tooltip>
<TooltipTrigger asChild>
<a
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={handleSignOut}
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>
target="_blank"
rel="noreferrer"
aria-label="Share feedback"
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-foreground/80 transition-colors hover:bg-background hover:text-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>
);
}

View File

@ -0,0 +1,9 @@
/**
* Shared visual contract for the Streamlined UI primary navigation surface.
* Settings reuses this contract when it takes over the global sidebar.
*/
export const primarySidebarStyles = {
surface: "bg-border/50 dark:bg-muted",
nav: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto scrollbar-auto-hide px-3 py-2 pointer-coarse:gap-3",
group: "flex flex-col gap-0.5",
} as const;

View File

@ -133,11 +133,17 @@ describe("TaskMessageScroller", () => {
expect(el.scrollTop).toBe(el.scrollHeight);
});
it("keeps the scrollbar at the full-width thread viewport edge", () => {
it("extends only the streamlined scroll box through the page gutter", () => {
render();
const frame = scroller().parentElement;
const el = scroller();
const frame = el.parentElement;
expect(frame?.className).toBe("relative min-h-0 flex-1");
expect(el.classList).toContain("-right-4");
expect(el.classList).toContain("pr-4");
expect(el.classList).toContain("md:-right-6");
expect(el.classList).toContain("md:pr-6");
expect(el.classList).not.toContain("right-0");
});
it("shows the scrollbar only while scroll activity is recent", () => {

View File

@ -215,12 +215,17 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
<div
ref={ref}
onScroll={handleScroll}
// absolute inset-0 (not h-full): the viewport must equal the flex-sized
// wrapper exactly — percentage heights don't reliably resolve against
// flex-determined block heights, which let the thread overflow the page.
// Keep the viewport tied to the flex-sized wrapper vertically —
// percentage heights don't reliably resolve against flex-determined
// block heights, which let the thread overflow the page. In the
// streamlined shell, extend only the scroll box through the page's
// right gutter; matching padding preserves the message column while
// placing the scrollbar against the properties-panel boundary.
className={cn(
"scrollbar-while-scrolling absolute inset-0 overflow-y-auto",
streamlined && "overflow-x-hidden",
"scrollbar-while-scrolling absolute inset-y-0 left-0 overflow-y-auto",
streamlined
? "-right-4 overflow-x-hidden pr-4 md:-right-6 md:pr-6"
: "right-0",
className,
)}
data-testid="task-chat-scroller"

View File

@ -174,14 +174,16 @@ describe("Connectors landing page", () => {
it("renders one connector list with the requested header and no gallery sections", async () => {
await renderBrowse();
expect(setBreadcrumbsMock).toHaveBeenCalledWith([]);
expect(setBreadcrumbsMock).toHaveBeenCalledWith([{ label: "Connectors" }]);
expect(setBreadcrumbsMock).not.toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ href: "/dashboard" }),
]));
expect(container.querySelector("header")?.textContent).toBe("Connectors");
expect(container.querySelector("header")?.textContent).not.toContain("Connectors");
expect(
container.querySelector('header input[aria-label="Search connectors"]'),
).toBeTruthy();
expect(container.querySelector("header")?.classList).toContain("justify-start");
expect(container.querySelector("header")?.classList).not.toContain("justify-end");
expect(container.querySelector('[aria-label="Popular apps"]')).toBeNull();
expect(container.querySelector('[aria-label="Connected apps"]')).toBeNull();
expect(container.querySelector('[aria-label="All apps"]')).toBeNull();

View File

@ -209,10 +209,7 @@ export function Browse() {
const [connectionToRemove, setConnectionToRemove] = useState<ConnectionRemovalTarget | null>(null);
useEffect(() => {
// Apps is already its own navigation root and the page has its own title.
// Leave the global bar empty instead of duplicating "Connectors" or
// offering a path back out of Apps.
setBreadcrumbs([]);
setBreadcrumbs([{ label: "Connectors" }]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs]);
@ -403,8 +400,7 @@ export function Browse() {
return (
<div className="max-w-5xl space-y-5 pb-12">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="shrink-0 text-xl font-bold text-foreground">Connectors</h1>
<header className="flex justify-start">
<div className="relative w-full max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input