diff --git a/ui/index.html b/ui/index.html index c41e22f2..0625148a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,9 +4,6 @@ - - - Paperclip diff --git a/ui/public/site.webmanifest b/ui/public/site.webmanifest index 907f6293..ea5a8bc9 100644 --- a/ui/public/site.webmanifest +++ b/ui/public/site.webmanifest @@ -5,7 +5,7 @@ "description": "AI-powered project management and agent coordination platform", "start_url": "/", "scope": "/", - "display": "standalone", + "display": "browser", "orientation": "any", "theme_color": "#18181b", "background_color": "#18181b", diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 379d6934..85a44ad6 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act } from "react"; import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Layout } from "./Layout"; @@ -27,6 +27,10 @@ const mockPluginSlots = vi.hoisted(() => ({ })); const mockUsePluginSlots = vi.hoisted(() => vi.fn()); const mockPluginSlotContexts = vi.hoisted(() => [] as Array>); +const mockSidebarState = vi.hoisted(() => ({ + sidebarOpen: true, + isMobile: false, +})); let currentPathname = "/PAP/dashboard"; vi.mock("@/lib/router", () => ({ @@ -35,8 +39,11 @@ vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate, useNavigationType: () => "PUSH", useParams: () => { - const firstSegment = currentPathname.split("/").filter(Boolean)[0]; - return { companyPrefix: firstSegment === "instance" ? undefined : firstSegment ?? "PAP" }; + const [firstSegment, secondSegment] = currentPathname.split("/").filter(Boolean); + return { + companyPrefix: firstSegment === "instance" ? undefined : firstSegment ?? "PAP", + pluginRoutePath: firstSegment === "instance" ? undefined : secondSegment, + }; }, })); @@ -161,10 +168,10 @@ vi.mock("../context/CompanyContext", () => ({ vi.mock("../context/SidebarContext", () => ({ useSidebar: () => ({ - sidebarOpen: true, + sidebarOpen: mockSidebarState.sidebarOpen, setSidebarOpen: mockSetSidebarOpen, toggleSidebar: vi.fn(), - isMobile: false, + isMobile: mockSidebarState.isMobile, }), })); @@ -201,6 +208,14 @@ vi.mock("../lib/main-content-focus", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + async function flushReact() { await act(async () => { await Promise.resolve(); @@ -229,6 +244,8 @@ describe("Layout", () => { }); mockPluginSlots.slots = []; mockPluginSlotContexts.length = 0; + mockSidebarState.sidebarOpen = true; + mockSidebarState.isMobile = false; }); afterEach(() => { @@ -319,6 +336,40 @@ describe("Layout", () => { }); }); + it("renders a mobile company settings selector on company settings routes", async () => { + currentPathname = "/PAP/company/settings/secrets"; + mockSidebarState.isMobile = true; + mockSidebarState.sidebarOpen = false; + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + const selector = container.querySelector("select"); + expect(selector).not.toBeNull(); + expect(selector?.value).toBe("secrets"); + expect(selector?.textContent).toContain("General"); + expect(selector?.textContent).toContain("Environments"); + expect(selector?.textContent).toContain("Cloud upstream"); + expect(selector?.textContent).toContain("Members"); + expect(selector?.textContent).toContain("Invites"); + expect(selector?.textContent).toContain("Secrets"); + + await act(async () => { + root.unmount(); + }); + }); + it("renders the instance settings sidebar on instance settings routes", async () => { currentPathname = "/instance/settings/general"; const root = createRoot(container); @@ -399,6 +450,61 @@ describe("Layout", () => { }); }); + it("keeps the route-scoped plugin sidebar on nested plugin page routes", async () => { + currentPathname = "/PAP/wiki/page/templates"; + mockPluginSlots.slots = [ + { + type: "page", + id: "wiki-page", + displayName: "Wiki Page", + exportName: "WikiPage", + routePath: "wiki", + pluginId: "plugin-1", + pluginKey: "wiki-plugin", + pluginDisplayName: "Wiki Plugin", + pluginVersion: "1.0.0", + }, + { + type: "routeSidebar", + id: "wiki-route-sidebar", + displayName: "Wiki Sidebar", + exportName: "WikiSidebar", + routePath: "wiki", + pluginId: "plugin-1", + pluginKey: "wiki-plugin", + pluginDisplayName: "Wiki Plugin", + pluginVersion: "1.0.0", + }, + ]; + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(mockUsePluginSlots).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: "company-1", + enabled: true, + }), + ); + expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar"); + expect(container.textContent).not.toContain("Main company nav"); + + await act(async () => { + root.unmount(); + }); + }); + it("uses the route company context for plugin route sidebars on the first render", async () => { currentPathname = "/ALT/wiki"; mockCompanyState.companies = [ diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 5f0567a6..d5a76fcd 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -4,6 +4,7 @@ import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from " import { Sidebar } from "./Sidebar"; import { InstanceSidebar } from "./InstanceSidebar"; import { CompanySettingsSidebar } from "./CompanySettingsSidebar"; +import { CompanySettingsNav } from "./access/CompanySettingsNav"; import { BreadcrumbBar } from "./BreadcrumbBar"; import { PropertiesPanel } from "./PropertiesPanel"; import { CommandPalette } from "./CommandPalette"; @@ -73,7 +74,10 @@ export function Layout() { selectionSource, setSelectedCompanyId, } = useCompany(); - const { companyPrefix } = useParams<{ companyPrefix: string }>(); + const { + companyPrefix, + pluginRoutePath: matchedPluginRoutePath, + } = useParams<{ companyPrefix: string; pluginRoutePath?: string }>(); const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); @@ -94,8 +98,8 @@ export function Layout() { const hasUnknownCompanyPrefix = Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany; const pluginRoutePath = useMemo( - () => getCompanyRouteSegment(location.pathname, companyPrefix), - [companyPrefix, location.pathname], + () => matchedPluginRoutePath?.toLowerCase() ?? getCompanyRouteSegment(location.pathname, companyPrefix), + [companyPrefix, location.pathname, matchedPluginRoutePath], ); const routeSidebarCompanyId = matchedCompany?.id ?? null; const routeSidebarCompanyPrefix = matchedCompany?.issuePrefix ?? null; @@ -421,6 +425,11 @@ export function Layout() { )} > + {isMobile && isCompanySettingsRoute ? ( +
+ +
+ ) : null}
{ const html = renderMarkdown("```ts\nconst a = 1;\n```"); expect(html).toContain("paperclip-markdown-codeblock"); + expect(html).toContain("paperclip-markdown-codeblock-actions"); + expect(html).toContain("paperclip-markdown-codeblock-wrap"); + expect(html).toContain('aria-label="Wrap lines"'); expect(html).toContain("paperclip-markdown-codeblock-copy"); expect(html).toContain('aria-label="Copy code"'); expect(html).toContain("lucide-copy"); }); + it("renders code block actions for indented preformatted markdown blocks", () => { + const html = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox"); + + expect(html).toContain("paperclip-markdown-codeblock"); + expect(html).toContain("paperclip-markdown-codeblock-wrap"); + expect(html).toContain('aria-label="Wrap lines"'); + expect(html).toContain("paperclip-markdown-codeblock-copy"); + }); + it("does not render a copy button on inline code", () => { const html = renderMarkdown("Reference `inline-code` here."); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 41ec495a..8c81de26 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -414,6 +414,7 @@ function CodeBlock({ ...mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined), ...(wrapLines ? { + overflowX: "hidden", whiteSpace: "pre-wrap", overflowWrap: "anywhere", wordBreak: "break-word", @@ -423,7 +424,10 @@ function CodeBlock({ > {children} -
+
- + +
+
+ Priority +
+ {priorities.map((p) => ( + + ))} +
+