mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
PAP-89: gate sidebar nav presentation on enableStreamlinedLeftNavigation
Branch the left-nav presentation on the experimental flag (default OFF = classic), navigation-only. All routes stay registered in both modes; destination pages are untouched. - Sidebar.tsx: read the flag once and thread a `streamlined` boolean. OFF (classic): "New Issue" button, "Issues" label, no top-level Projects link, restore <SidebarProjects /> collapsible below Work. ON (streamlined, current): "New Task", "Tasks", Projects nav item -> /projects. - SidebarAgents.tsx: accept `streamlined` prop; OFF restores show-all (no active/recent-5 filter, no "See all agents" link). - MobileBottomNav.tsx: mirror the Tasks/Issues label set on the flag. - D5 (option A): classic does NOT add a top-level /projects link; /projects stays registered and reachable by URL. - Tests: Sidebar.test + SidebarAgents.test cover BOTH flag states. Verified live toggle at /instance/settings/experimental switches classic <-> streamlined in light + dark (Playwright). ui typecheck clean; 29 sidebar tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
c007fc19f0
commit
69a97eabe8
5 files changed
+149
-21
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink, useLocation } from "@/lib/router";
|
||||
import {
|
||||
House,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
@@ -39,11 +42,17 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
// IA flag (PAP-89): mirror the sidebar's classic/streamlined label set.
|
||||
const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation === true;
|
||||
|
||||
const items = useMemo<MobileNavItem[]>(
|
||||
() => [
|
||||
{ type: "link", to: "/dashboard", label: "Home", icon: House },
|
||||
{ type: "link", to: "/issues", label: "Tasks", icon: CircleDot },
|
||||
{ type: "link", to: "/issues", label: streamlined ? "Tasks" : "Issues", icon: CircleDot },
|
||||
{ type: "action", label: "Create", icon: SquarePen, onClick: () => openNewIssue() },
|
||||
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
|
||||
{
|
||||
@@ -54,7 +63,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||
badge: inboxBadge.inbox,
|
||||
},
|
||||
],
|
||||
[openNewIssue, inboxBadge.inbox],
|
||||
[openNewIssue, inboxBadge.inbox, streamlined],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -83,7 +83,13 @@ vi.mock("./SidebarCompanyMenu", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarAgents", () => ({
|
||||
SidebarAgents: () => null,
|
||||
SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => (
|
||||
<div data-testid="sidebar-agents" data-streamlined={String(streamlined)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarProjects", () => ({
|
||||
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -144,7 +150,10 @@ describe("Sidebar", () => {
|
||||
});
|
||||
|
||||
it("renders plugin sidebar launchers inside the Work section", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
const workSection = [...container.querySelectorAll("nav [data-plugin-launcher-zone]")]
|
||||
@@ -160,6 +169,60 @@ describe("Sidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("streamlined (flag ON): New Task button, Tasks label, top-level Projects link, no per-project collapsible", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("New Task");
|
||||
expect(container.textContent).not.toContain("New Issue");
|
||||
|
||||
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
|
||||
expect(navLabels).toContain("Tasks");
|
||||
expect(navLabels).not.toContain("Issues");
|
||||
|
||||
const projectsLink = [...container.querySelectorAll("nav a")].find((a) => a.textContent?.trim() === "Projects");
|
||||
expect(projectsLink?.getAttribute("href")).toBe("/projects");
|
||||
|
||||
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("classic (flag OFF): New Issue button, Issues label, per-project collapsible, no top-level Projects link", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("New Issue");
|
||||
expect(container.textContent).not.toContain("New Task");
|
||||
|
||||
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
|
||||
expect(navLabels).toContain("Issues");
|
||||
expect(navLabels).not.toContain("Tasks");
|
||||
// No top-level Projects nav link in classic mode (D5 option A).
|
||||
expect(navLabels).not.toContain("Projects");
|
||||
|
||||
// Per-project collapsible restored below Work.
|
||||
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders plugin sidebar slots in Work below Workspaces", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
const root = await renderSidebar();
|
||||
|
||||
@@ -19,6 +19,7 @@ import { NavLink } from "@/lib/router";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
@@ -46,6 +47,11 @@ export function Sidebar() {
|
||||
});
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
// IA flag (PAP-89): branch the sidebar nav presentation. Default OFF = classic
|
||||
// ("New Issue", "Issues", per-project collapsible, no Projects nav link).
|
||||
// ON = streamlined (current: "New Task", "Tasks", top-level Projects link).
|
||||
// Gating is navigation-only; all routes stay registered in both modes.
|
||||
const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation === true;
|
||||
|
||||
const pluginContext = {
|
||||
companyId: selectedCompanyId,
|
||||
@@ -73,14 +79,14 @@ export function Sidebar() {
|
||||
|
||||
<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">
|
||||
{/* New Task button aligned with nav items */}
|
||||
{/* New Task / New Issue button aligned with nav items */}
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
data-slot="icon-button"
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Task</span>
|
||||
<span className="truncate">{streamlined ? "New Task" : "New Issue"}</span>
|
||||
</button>
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||
<SidebarNavItem
|
||||
@@ -94,13 +100,15 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
|
||||
<SidebarNavItem to="/issues" label={streamlined ? "Tasks" : "Issues"} icon={CircleDot} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
|
||||
{streamlined ? (
|
||||
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
|
||||
) : null}
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["sidebar"]}
|
||||
context={pluginContext}
|
||||
@@ -116,7 +124,10 @@ export function Sidebar() {
|
||||
/>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarAgents />
|
||||
{/* Classic mode restores the per-project collapsible below Work. */}
|
||||
{streamlined ? null : <SidebarProjects />}
|
||||
|
||||
<SidebarAgents streamlined={streamlined} />
|
||||
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
|
||||
@@ -258,14 +258,14 @@ describe("SidebarAgents", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderSidebarAgents() {
|
||||
async function renderSidebarAgents(streamlined = true) {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents />
|
||||
<SidebarAgents streamlined={streamlined} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
@@ -514,6 +514,47 @@ describe("SidebarAgents", () => {
|
||||
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
|
||||
});
|
||||
|
||||
it("classic mode (flag OFF) shows all agents and no See all link even when one is running", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
|
||||
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
|
||||
]);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
|
||||
{ id: "run-1", agentId: "agent-b", status: "running" },
|
||||
]);
|
||||
|
||||
await renderSidebarAgents(false);
|
||||
|
||||
// Show-all: every agent is listed regardless of live-run state. (Bravo's
|
||||
// label includes its live-run badge text, so match by prefix.)
|
||||
const labels = agentLinkLabels(container);
|
||||
expect(labels).toHaveLength(3);
|
||||
expect(labels[0]).toBe("Alpha");
|
||||
expect(labels[1]).toContain("Bravo");
|
||||
expect(labels[2]).toBe("Charlie");
|
||||
// No recent-5 truncation, so no "See all agents" link in classic mode.
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("classic mode (flag OFF) shows more than 5 agents without truncation", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
makeAgent({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
urlKey: `agent-${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
|
||||
await renderSidebarAgents(false);
|
||||
|
||||
expect(agentLinkLabels(container)).toHaveLength(7);
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not offer sidebar resume for budget-paused agents", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({
|
||||
|
||||
@@ -216,7 +216,7 @@ function SidebarAgentItem({
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarAgents() {
|
||||
export function SidebarAgents({ streamlined = true }: { streamlined?: boolean } = {}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const queryClient = useQueryClient();
|
||||
@@ -283,20 +283,24 @@ export function SidebarAgents() {
|
||||
[orderedAgents, sortMode],
|
||||
);
|
||||
|
||||
// IA Phase 5: if any agent has a live run, show only those active agents.
|
||||
// Otherwise fall back to up to RECENT_AGENT_LIMIT agents. Either way a
|
||||
// "See all agents" link is shown so the full list is always reachable.
|
||||
// IA Phase 5 (streamlined): if any agent has a live run, show only those
|
||||
// active agents. Otherwise fall back to up to RECENT_AGENT_LIMIT agents. Either
|
||||
// way a "See all agents" link is shown so the full list is always reachable.
|
||||
// Classic mode (PAP-89, flag OFF) restores the show-all behavior.
|
||||
const runningAgents = useMemo(
|
||||
() => sortedAgents.filter((agent: Agent) => (liveCountByAgent.get(agent.id) ?? 0) > 0),
|
||||
[sortedAgents, liveCountByAgent],
|
||||
);
|
||||
const hasActiveAgents = runningAgents.length > 0;
|
||||
const displayedAgents = hasActiveAgents
|
||||
? runningAgents
|
||||
: sortedAgents.slice(0, RECENT_AGENT_LIMIT);
|
||||
// Always expose "See all agents" whenever there are agents — even in the
|
||||
// active-only state — so users never lose the entry point to the full list.
|
||||
const showSeeAllLink = sortedAgents.length > 0;
|
||||
const displayedAgents = !streamlined
|
||||
? sortedAgents
|
||||
: hasActiveAgents
|
||||
? runningAgents
|
||||
: sortedAgents.slice(0, RECENT_AGENT_LIMIT);
|
||||
// Always expose "See all agents" whenever the displayed list is a subset of all
|
||||
// agents, so users never lose the entry point to the full list. In classic mode
|
||||
// every agent is already shown, so the link is unnecessary.
|
||||
const showSeeAllLink = streamlined && sortedAgents.length > 0;
|
||||
|
||||
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
|
||||
const activeAgentId = agentMatch?.[1] ?? null;
|
||||
|
||||
Reference in new issue
Block a user