[codex] Polish board UI mobile flows (#6550)
## 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 <noreply@paperclip.ing>
This commit is contained in:
@@ -4,9 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#18181b" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Paperclip" />
|
||||
<title>Paperclip</title>
|
||||
<!-- PAPERCLIP_RUNTIME_BRANDING_START -->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Record<string, unknown>>);
|
||||
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<void>) {
|
||||
let result: void | Promise<void> = 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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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 = [
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
>
|
||||
<BreadcrumbBar />
|
||||
{isMobile && isCompanySettingsRoute ? (
|
||||
<div className="border-b border-border px-4 pb-3">
|
||||
<CompanySettingsNav />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||
<main
|
||||
|
||||
@@ -449,11 +449,23 @@ describe("MarkdownBody", () => {
|
||||
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.");
|
||||
|
||||
|
||||
@@ -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}
|
||||
</pre>
|
||||
<div className="paperclip-markdown-codeblock-actions">
|
||||
<div
|
||||
className="paperclip-markdown-codeblock-actions"
|
||||
data-active={copied || failed || wrapLines || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWrapLines((value) => !value)}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
...props
|
||||
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("MarkdownBody code block wrapping", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("toggles fenced code blocks between horizontal scroll and wrapped lines", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{"```text\nlong line that can wrap when requested\n```"}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const pre = container.querySelector("pre");
|
||||
const actions = container.querySelector<HTMLDivElement>(
|
||||
".paperclip-markdown-codeblock-actions",
|
||||
);
|
||||
const wrapButton = container.querySelector<HTMLButtonElement>(
|
||||
".paperclip-markdown-codeblock-wrap",
|
||||
);
|
||||
|
||||
expect(pre).not.toBeNull();
|
||||
expect(actions).not.toBeNull();
|
||||
expect(wrapButton).not.toBeNull();
|
||||
expect(actions?.getAttribute("data-active")).toBeNull();
|
||||
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
|
||||
expect(pre?.style.overflowX).toBe("auto");
|
||||
expect(pre?.style.whiteSpace).toBe("");
|
||||
|
||||
flushSync(() => {
|
||||
wrapButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(wrapButton?.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Unwrap lines");
|
||||
expect(actions?.getAttribute("data-active")).toBe("true");
|
||||
expect(pre?.style.overflowX).toBe("hidden");
|
||||
expect(pre?.style.whiteSpace).toBe("pre-wrap");
|
||||
expect(pre?.style.overflowWrap).toBe("anywhere");
|
||||
|
||||
flushSync(() => {
|
||||
wrapButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
|
||||
expect(actions?.getAttribute("data-active")).toBeNull();
|
||||
expect(pre?.style.overflowX).toBe("auto");
|
||||
expect(pre?.style.whiteSpace).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -226,6 +226,16 @@ vi.mock("@/components/ui/popover", () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void | Promise<void>): void | Promise<void> {
|
||||
let result: unknown;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
return result && typeof (result as Promise<void>).then === "function"
|
||||
? (result as Promise<void>).then(() => undefined)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -722,10 +732,12 @@ describe("NewIssueDialog", () => {
|
||||
await flush();
|
||||
|
||||
const dialogContent = Array.from(container.querySelectorAll("div")).find((element) =>
|
||||
typeof element.className === "string" && element.className.includes("max-h-[calc(100dvh-2rem)]"),
|
||||
typeof element.className === "string" && element.className.includes("max-h-[var(--new-issue-dialog-height)]"),
|
||||
);
|
||||
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
|
||||
expect(dialogContent?.className).toContain("h-[var(--new-issue-dialog-height)]");
|
||||
expect(dialogContent?.className).toContain("overflow-hidden");
|
||||
expect(dialogContent?.getAttribute("style")).toContain("env(safe-area-inset-top)");
|
||||
expect(dialogContent?.getAttribute("style")).toContain("env(safe-area-inset-bottom)");
|
||||
|
||||
const titleInput = container.querySelector('textarea[placeholder="Issue title"]');
|
||||
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]');
|
||||
@@ -740,6 +752,28 @@ describe("NewIssueDialog", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps priority under the mobile overflow menu", async () => {
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
const priorityChip = container.querySelector('[data-testid="new-issue-priority-chip"]');
|
||||
expect(priorityChip?.className).toContain("hidden");
|
||||
expect(priorityChip?.className).toContain("sm:inline-flex");
|
||||
|
||||
const highPriorityOption = container.querySelector('[data-testid="new-issue-more-priority-high"]');
|
||||
expect(highPriorityOption?.textContent).toContain("High");
|
||||
|
||||
await act(async () => {
|
||||
highPriorityOption?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const selectedHighPriorityOption = container.querySelector('[data-testid="new-issue-more-priority-high"]');
|
||||
expect(selectedHighPriorityOption?.className).toContain("bg-accent");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("allows editor autocomplete portal pointer events inside the modal", async () => {
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent, type RefObject } from "react";
|
||||
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { IssueWorkMode } from "@paperclipai/shared";
|
||||
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
||||
@@ -70,6 +70,7 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
|
||||
|
||||
const DRAFT_KEY = "paperclip:issue-draft";
|
||||
const DEBOUNCE_MS = 800;
|
||||
const MOBILE_DIALOG_HEIGHT = "calc(100dvh - max(1rem, env(safe-area-inset-top)) - max(1rem, env(safe-area-inset-bottom)))";
|
||||
|
||||
|
||||
interface IssueDraft {
|
||||
@@ -1202,10 +1203,11 @@ export function NewIssueDialog() {
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
aria-describedby={undefined}
|
||||
style={{ "--new-issue-dialog-height": MOBILE_DIALOG_HEIGHT } as CSSProperties}
|
||||
className={cn(
|
||||
"flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-auto",
|
||||
"flex h-[var(--new-issue-dialog-height)] max-h-[var(--new-issue-dialog-height)] flex-col gap-0 overflow-hidden p-0 sm:h-auto",
|
||||
expanded
|
||||
? "sm:max-w-2xl sm:h-[calc(100dvh-2rem)]"
|
||||
? "sm:max-w-2xl sm:h-[var(--new-issue-dialog-height)]"
|
||||
: "sm:max-w-lg"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -1868,7 +1870,11 @@ export function NewIssueDialog() {
|
||||
{/* Priority chip */}
|
||||
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="new-issue-priority-chip"
|
||||
className="hidden items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs transition-colors hover:bg-accent/50 sm:inline-flex"
|
||||
>
|
||||
{currentPriority ? (
|
||||
<>
|
||||
<currentPriority.icon className={cn("h-3 w-3", currentPriority.color)} />
|
||||
@@ -1964,14 +1970,42 @@ export function NewIssueDialog() {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* More (dates) */}
|
||||
{/* More */}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="new-issue-more-menu-trigger"
|
||||
className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="start">
|
||||
<PopoverContent className="w-44 p-1" align="start" data-testid="new-issue-more-menu">
|
||||
<div className="sm:hidden">
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase text-muted-foreground">
|
||||
Priority
|
||||
</div>
|
||||
{priorities.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p.value}
|
||||
data-testid={`new-issue-more-priority-${p.value}`}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||
p.value === priority && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setPriority(p.value);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<p.icon className={cn("h-3 w-3", p.color)} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 border-t border-border" />
|
||||
</div>
|
||||
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Start date
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CompanySettingsNav, getCompanySettingsTab } from "./CompanySettingsNav";
|
||||
|
||||
@@ -40,6 +40,14 @@ vi.mock("@/components/PageTabBar", () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
describe("CompanySettingsNav", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -60,11 +68,13 @@ describe("CompanySettingsNav", () => {
|
||||
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
|
||||
expect(getCompanySettingsTab("/company/settings/cloud-upstream")).toBe("cloud-upstream");
|
||||
expect(getCompanySettingsTab("/company/settings/members")).toBe("members");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members");
|
||||
expect(getCompanySettingsTab("/company/settings/access")).toBe("members");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members");
|
||||
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/secrets")).toBe("secrets");
|
||||
});
|
||||
|
||||
it("renders the active tab and navigates when a different tab is selected", async () => {
|
||||
@@ -82,8 +92,10 @@ describe("CompanySettingsNav", () => {
|
||||
items: [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "environments", label: "Environments" },
|
||||
{ value: "cloud-upstream", label: "Cloud upstream" },
|
||||
{ value: "members", label: "Members" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
{ value: "secrets", label: "Secrets" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useLocation, useNavigate } from "@/lib/router";
|
||||
const items = [
|
||||
{ value: "general", label: "General", href: "/company/settings" },
|
||||
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
|
||||
{ value: "cloud-upstream", label: "Cloud upstream", href: "/company/settings/cloud-upstream" },
|
||||
{ value: "members", label: "Members", href: "/company/settings/members" },
|
||||
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
|
||||
{ value: "secrets", label: "Secrets", href: "/company/settings/secrets" },
|
||||
] as const;
|
||||
|
||||
type CompanySettingsTab = (typeof items)[number]["value"];
|
||||
@@ -16,6 +18,10 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
||||
return "environments";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/cloud-upstream")) {
|
||||
return "cloud-upstream";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/members") || pathname.includes("/company/settings/access")) {
|
||||
return "members";
|
||||
}
|
||||
@@ -24,6 +30,10 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
||||
return "invites";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/secrets")) {
|
||||
return "secrets";
|
||||
}
|
||||
|
||||
return "general";
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -750,8 +750,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||
|
||||
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions,
|
||||
.paperclip-markdown-codeblock-actions:focus-within,
|
||||
.paperclip-markdown-codeblock-action[data-copied],
|
||||
.paperclip-markdown-codeblock-action[data-active] {
|
||||
.paperclip-markdown-codeblock-actions[data-active] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const uiRoot = resolve(fileURLToPath(new URL("../..", import.meta.url)));
|
||||
|
||||
describe("PWA install mode", () => {
|
||||
it("opens home-screen launches with browser controls visible", () => {
|
||||
const manifest = JSON.parse(readFileSync(resolve(uiRoot, "public/site.webmanifest"), "utf8")) as {
|
||||
display?: string;
|
||||
};
|
||||
const html = readFileSync(resolve(uiRoot, "index.html"), "utf8");
|
||||
|
||||
expect(manifest.display).toBe("browser");
|
||||
expect(html).not.toContain('name="mobile-web-app-capable"');
|
||||
expect(html).not.toContain('name="apple-mobile-web-app-capable"');
|
||||
expect(html).not.toContain('name="apple-mobile-web-app-status-bar-style"');
|
||||
});
|
||||
});
|
||||
@@ -933,7 +933,7 @@ export function CompanyExport() {
|
||||
{/* Sticky top action bar */}
|
||||
<div className="sticky top-0 z-10 border-b border-border bg-background px-5 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<span className="font-medium">
|
||||
{selectedCompany?.name ?? "Company"} export
|
||||
</span>
|
||||
@@ -969,8 +969,8 @@ export function CompanyExport() {
|
||||
)}
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid h-[calc(100vh-12rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
<aside className="flex flex-col border-r border-border overflow-hidden">
|
||||
<div className="grid gap-4 xl:h-[calc(100vh-12rem)] xl:grid-cols-[19rem_minmax(0,1fr)] xl:gap-0">
|
||||
<aside className="flex max-h-[24rem] flex-col overflow-hidden border-b border-border xl:max-h-none xl:border-b-0 xl:border-r">
|
||||
<div className="border-b border-border px-4 py-3 shrink-0">
|
||||
<h2 className="text-base font-semibold">Package files</h2>
|
||||
</div>
|
||||
@@ -1011,7 +1011,7 @@ export function CompanyExport() {
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 overflow-y-auto pl-6">
|
||||
<div className="min-w-0 overflow-y-auto xl:pl-6">
|
||||
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1227,7 +1227,7 @@ export function CompanyImport() {
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1287,7 +1287,7 @@ export function CompanyImport() {
|
||||
/>
|
||||
|
||||
{/* Import button — below renames */}
|
||||
<div className="mx-5 mt-3 flex justify-end">
|
||||
<div className="mx-5 mt-3 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => importMutation.mutate()}
|
||||
@@ -1319,8 +1319,8 @@ export function CompanyImport() {
|
||||
)}
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid h-[calc(100vh-16rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
<aside className="flex flex-col border-r border-border overflow-hidden">
|
||||
<div className="grid gap-4 xl:h-[calc(100vh-16rem)] xl:grid-cols-[19rem_minmax(0,1fr)] xl:gap-0">
|
||||
<aside className="flex max-h-[24rem] flex-col overflow-hidden border-b border-border xl:max-h-none xl:border-b-0 xl:border-r">
|
||||
<div className="border-b border-border px-4 py-3 shrink-0">
|
||||
<h2 className="text-base font-semibold">Package files</h2>
|
||||
</div>
|
||||
@@ -1339,7 +1339,7 @@ export function CompanyImport() {
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 overflow-y-auto pl-6">
|
||||
<div className="min-w-0 overflow-y-auto xl:pl-6">
|
||||
<ImportPreviewPane
|
||||
selectedFile={selectedFile}
|
||||
content={previewContent}
|
||||
|
||||
@@ -145,6 +145,8 @@ export function Issues() {
|
||||
includeRoutineExecutions: true,
|
||||
limit: issuePageSize,
|
||||
offset: pageParam,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, _allPages, lastPageParam) =>
|
||||
|
||||
Reference in New Issue
Block a user