From 90117827eba34104ca1a4cefcc4acae7214a9f40 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 22 May 2026 10:13:47 -0500 Subject: [PATCH] [codex] Polish board UI mobile flows (#6550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the board UI and control plane for supervising AI-agent companies. > - Operators repeatedly use mobile navigation, issue creation, inbox scanning, and markdown reading surfaces. > - Small layout and interaction rough edges add friction to those high-frequency workflows. > - The branch included a set of related board UI polish changes that were too small to review as many separate PRs. > - This pull request groups the remaining mobile/navigation/markdown polish into one standalone branch. > - The benefit is smoother board operation without mixing in unrelated backend feature work. ## What Changed - Tightened company settings navigation behavior on mobile. - Fixed mobile new issue dialog height and moved issue priority into the overflow controls on small screens. - Restored browser controls for home-screen app mode. - Fixed plugin-route sidebar selection on nested page loads. - Added markdown preformatted-block wrapping controls and coverage. - Kept updated issue list pages sorted by updated time in the board UI. ## Verification - `pnpm --filter @paperclipai/plugin-sdk build` - `NODE_ENV=test pnpm exec vitest run ui/src/components/Layout.test.tsx ui/src/components/MarkdownBody.test.tsx ui/src/components/MarkdownBody.wrap.test.tsx ui/src/components/NewIssueDialog.test.tsx ui/src/components/access/CompanySettingsNav.test.tsx ui/src/lib/pwa-install-mode.test.ts ui/src/pages/Inbox.test.tsx` The targeted UI tests passed. React emitted existing act-wrapping warnings in a few test files, but there were no test failures. ## Risks - Medium-low: changes span several UI surfaces, but they are mostly layout/interaction polish with targeted component tests. - Visual screenshots are not newly captured in this split PR; follow-up review should include browser/visual QA before marking ready. > 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 GPT-5 Codex via `codex_local`, tool-enabled coding session; exact context window not exposed by this runtime. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/index.html | 3 - ui/public/site.webmanifest | 2 +- ui/src/components/Layout.test.tsx | 116 +++++++++++++++++- ui/src/components/Layout.tsx | 15 ++- ui/src/components/MarkdownBody.test.tsx | 12 ++ ui/src/components/MarkdownBody.tsx | 6 +- ui/src/components/MarkdownBody.wrap.test.tsx | 100 +++++++++++++++ ui/src/components/NewIssueDialog.test.tsx | 40 +++++- ui/src/components/NewIssueDialog.tsx | 48 ++++++-- .../access/CompanySettingsNav.test.tsx | 14 ++- .../components/access/CompanySettingsNav.tsx | 10 ++ ui/src/index.css | 3 +- ui/src/lib/pwa-install-mode.test.ts | 20 +++ ui/src/pages/CompanyExport.tsx | 8 +- ui/src/pages/CompanyImport.tsx | 10 +- ui/src/pages/Issues.tsx | 2 + 16 files changed, 374 insertions(+), 35 deletions(-) create mode 100644 ui/src/components/MarkdownBody.wrap.test.tsx create mode 100644 ui/src/lib/pwa-install-mode.test.ts 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) => ( + + ))} +
+