diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 66a71b46b5f..bd934c1703c 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -81,6 +81,14 @@ export const INSTANCE_FEATURE_CATALOG: Record { + it("defaults the streamlined UI on and accepts an explicit patch", () => { + expect(instanceExperimentalSettingsSchema.parse({}).enableStreamlinedUi).toBe(true); + expect( + patchInstanceExperimentalSettingsSchema.parse({ enableStreamlinedUi: false }), + ).toEqual({ enableStreamlinedUi: false }); + }); + it("defaults the server info debug view off", () => { const settings = instanceExperimentalSettingsSchema.parse({}); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 6a392d3f0dd..a23c3615743 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -45,6 +45,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableManagedSandboxOnly: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(true), + enableStreamlinedUi: z.boolean().default(true), // Deprecated compatibility key. Apps is a standard product surface and is // always enabled; this remains accepted so older stored rows and managed // configs continue to load during upgrades. diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 455fbeb4fc0..4c5bc175797 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -270,6 +270,24 @@ describe("instance settings routes", () => { .expect(404); }); + it("accepts the instance-wide Streamlined UI preference", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableStreamlinedUi: false }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableStreamlinedUi: false, + }); + }); + it("strips server-managed worktree run execution fields before updating experimental settings", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 569fb6685b3..475a2fdd5a8 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -29,6 +29,7 @@ describe("instance settings service", () => { enableManagedSandboxOnly: false, enableIsolatedWorkspaces: true, enableStreamlinedLeftNavigation: true, + enableStreamlinedUi: true, enableApps: true, enableConferenceRoomChat: false, enableClassicTaskInterface: false, @@ -59,6 +60,15 @@ describe("instance settings service", () => { }); }); + it("defaults streamlined UI on without inheriting the retired navigation preference", () => { + expect(normalizeExperimentalSettings(undefined).enableStreamlinedUi).toBe(true); + expect(normalizeExperimentalSettings({}).enableStreamlinedUi).toBe(true); + expect( + normalizeExperimentalSettings({ enableStreamlinedLeftNavigation: false }).enableStreamlinedUi, + ).toBe(true); + expect(normalizeExperimentalSettings({ enableStreamlinedUi: false }).enableStreamlinedUi).toBe(false); + }); + it("keeps Apps on for empty, legacy, and explicitly disabled stored settings", () => { expect(normalizeExperimentalSettings(undefined).enableApps).toBe(true); expect(normalizeExperimentalSettings({}).enableApps).toBe(true); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index d590dde57e1..b6954556128 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -224,6 +224,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true, + enableStreamlinedUi: parsed.data.enableStreamlinedUi ?? true, // Apps graduated from Experimental. Ignore historical off values while // continuing to accept the compatibility key in stored settings. enableApps: true, @@ -262,6 +263,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableManagedSandboxOnly: false, enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: true, + enableStreamlinedUi: true, enableApps: true, enablePipelines: false, enableCases: false, diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts index f1a5282569c..bbc1de02f7b 100644 --- a/tests/e2e/apps-dark-mode-shots.spec.ts +++ b/tests/e2e/apps-dark-mode-shots.spec.ts @@ -161,7 +161,7 @@ test.describe.serial("dark-mode Apps surfaces", () => { await expect(page.locator('a[href$="/apps/advanced/gateways"]', { hasText: "Gateways" })).toHaveCount(0); await expect(page.locator('a[href$="/apps/advanced/profiles"]', { hasText: "Profiles" })).toHaveCount(0); await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toHaveCount(0); - await expect(page.locator('a[href$="/activity"]', { hasText: "Activity" })).toBeVisible(); + await expect(page.locator('a[href$="/activity"]', { hasText: "Audit" })).toBeVisible(); await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0); // Apps section lives in the same sidebar now. await expect(page.locator('a[href$="/apps"]', { hasText: "Connectors" })).toBeVisible(); diff --git a/tests/e2e/sidebar-takeover.spec.ts b/tests/e2e/sidebar-takeover.spec.ts index ffbfa746ba8..af55e6fff86 100644 --- a/tests/e2e/sidebar-takeover.spec.ts +++ b/tests/e2e/sidebar-takeover.spec.ts @@ -1,37 +1,20 @@ import { test, expect, request as pwRequest, type APIRequestContext } from "@playwright/test"; /** - * E2E: Sidebar takeover model (PAP-10695). + * E2E: contextual sidebar companion model. * - * Takeover routes (company settings, plugin `routeSidebar`) no longer *replace* - * the main app sidebar. Instead the host collapses the app `` to its - * 64px rail (still peek-able) and renders the contextual sidebar in a second - * pane → `[ app rail ][ secondary ~240px ][ content ]`. + * 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. * - * These specs assert the rail + secondary pane coexist on a company settings - * route, and that an explicit user pin (expanded) wins over the route-driven - * collapse (pin precedence). - * - * The plugin `routeSidebar` half of this behavior shares the exact same Layout - * code path (one `secondarySidebar`/`hasSecondarySidebar` resolver drives both - * company-settings and plugin routes) and is covered by the unit tests in - * `ui/src/components/Layout.test.tsx`. A live plugin-route e2e requires the - * `plugin-llm-wiki` plugin to be installed in the throwaway e2e instance, which - * is out of scope for this default local_trusted run; visual QA of both panes - * is delegated to the QA child issue. + * 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. */ const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199); const BASE_URL = `http://127.0.0.1:${PORT}`; const COMPANY_NAME_PREFIX = "E2E-SidebarTakeover"; const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed"; - -// The sidebar header's "Collapse sidebar" toggle only renders when the app -// sidebar is expanded (pinned, desktop, not takeover-locked); in the collapsed -// rail and on takeover routes it is hidden. Its presence/absence is therefore -// a stable proxy for the app sidebar's collapsed state (see Sidebar.tsx). -// (Previously "Open search", but the header search icon moved into the nav, -// where it renders in the rail too.) const APP_SIDEBAR_EXPANDED_MARKER = "Collapse sidebar"; async function createCompany(board: APIRequestContext): Promise<{ id: string; prefix: string }> { @@ -53,7 +36,7 @@ async function createCompany(board: APIRequestContext): Promise<{ id: string; pr }; } -test.describe("Sidebar takeover (collapse + secondary pane)", () => { +test.describe("Contextual sidebar companion", () => { let board: APIRequestContext; let companyId: string; let prefix: string; @@ -71,93 +54,59 @@ test.describe("Sidebar takeover (collapse + secondary pane)", () => { }); test.beforeEach(async ({ page }) => { - // Start each test from a clean (unpinned) sidebar state so the route-driven - // collapse is the only thing acting on it. await page.addInitScript((key) => { window.localStorage.removeItem(key); + window.sessionStorage.clear(); }, COLLAPSED_STORAGE_KEY); }); - test("collapses the app sidebar to its rail and shows the settings sidebar beside it", async ({ page }) => { + test("shows Settings beside the global navigation", async ({ page }) => { await page.goto(`/${prefix}/company/settings`); - // The contextual (secondary) pane is present... - const secondary = page.locator("[data-secondary-sidebar]"); - await expect(secondary).toBeVisible(); - await expect(secondary).toHaveCount(1); + const contextual = page.locator('[data-contextual-sidebar="settings"]'); + await expect(contextual).toBeVisible(); + await expect(contextual).toHaveCount(1); + await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(1); - // ...and it is ~240px wide (w-60), distinct from the 64px app rail. - const secondaryBox = await secondary.boundingBox(); - expect(secondaryBox).not.toBeNull(); - expect(secondaryBox!.width).toBeGreaterThan(180); + 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(page.getByRole("button", { name: "Open account menu" })).toBeVisible(); - // The app sidebar is NOT replaced — its company nav still renders... await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible(); - - // ...but it is collapsed to its rail: the expanded-only "Open search" - // header control is hidden. await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0); }); - test("renders the secondary pane nav labels at full width despite the app rail collapse", async ({ page }) => { - // Regression (PAP-10700): the secondary pane is 240px wide, but its - // SidebarNavItem children read the *global* collapsed state and used to - // render icon-only (label `w-0 text-transparent`), making the settings nav - // unreadable in the default takeover state. The pane must force full labels. + test("renders contextual labels at full width", async ({ page }) => { await page.goto(`/${prefix}/company/settings`); - const secondary = page.locator("[data-secondary-sidebar]"); - await expect(secondary).toBeVisible(); - - // App sidebar is collapsed to its rail (default unpinned takeover state)... - await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0); - - // ...yet a settings nav label renders at its full text width, not clipped to - // zero. "Environments" is unique to the company-settings nav. - const envLabel = secondary.getByText("Environments", { exact: true }); + const contextual = page.locator('[data-contextual-sidebar="settings"]'); + const envLabel = contextual.getByText("Environments", { exact: true }); await expect(envLabel).toBeVisible(); const labelBox = await envLabel.boundingBox(); expect(labelBox).not.toBeNull(); expect(labelBox!.width).toBeGreaterThan(20); }); - test("settings force-collapse overrides an expanded pin without mutating it", async ({ page }) => { - // User has pinned the sidebar expanded ("0"). Company settings is a hard - // secondary-sidebar takeover route, so forceCollapsed wins while the route is - // active (force > pin > route request > default) but must not mutate the pin. - await page.addInitScript( - ({ key }) => { - window.localStorage.setItem(key, "0"); - }, - { key: COLLAPSED_STORAGE_KEY }, - ); - + test("keeps the retired collapse control absent across contextual navigation", async ({ page }) => { await page.goto(`/${prefix}/company/settings`); - - // Secondary pane still shows on the takeover route. - await expect(page.locator("[data-secondary-sidebar]")).toBeVisible(); - - // The app sidebar is hard-collapsed despite the stored expanded pin. + await expect(page.locator('[data-contextual-sidebar="settings"]')).toBeVisible(); + await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible(); await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0); await page.goto(`/${prefix}/dashboard`); - // Leaving the takeover route clears the force and restores the user's - // persisted expanded pin. - await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(0); - await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toBeVisible(); + await expect(page.locator("[data-contextual-sidebar]")).toHaveCount(0); + await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible(); + await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0); }); - test("leaving the takeover route removes the secondary pane and restores the sidebar", async ({ page }) => { + test("uses Dashboard as the safe fallback for a direct Settings link", async ({ page }) => { await page.goto(`/${prefix}/company/settings`); - await expect(page.locator("[data-secondary-sidebar]")).toBeVisible(); - await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0); + await page.getByRole("button", { name: "Back from Settings" }).click(); - // Navigate to a plain (non-takeover) route. - await page.goto(`/${prefix}/dashboard`); - - // No secondary pane, and the app sidebar is no longer force-collapsed. - await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(0); - await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/${prefix}/dashboard$`)); + await expect(page.locator("[data-contextual-sidebar]")).toHaveCount(0); + await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible(); }); }); diff --git a/ui/src/App.activity-routing.test.tsx b/ui/src/App.activity-routing.test.tsx index 88c1e3c8c7d..b87aec44051 100644 --- a/ui/src/App.activity-routing.test.tsx +++ b/ui/src/App.activity-routing.test.tsx @@ -1,11 +1,10 @@ // @vitest-environment jsdom -// Regression guard for PAP-16302: `/audit` was merged into the single rich -// Activity page. Both the company-prefixed `/:company/audit` and the bare -// `/audit` (PAP-16300's unprefixed redirect) must keep resolving — as redirects -// into `/:company/activity?mode=agents`, so old deep links land on the -// agent-actions scope instead of 404ing. This drives the real route table -// so removing either registration fails loudly. +// Regression guard for the Audit hub: Activity remains the canonical root, +// while Runs, Costs, and Budgets live beneath it. Company-prefixed and bare +// legacy routes must keep resolving with their query filters intact; `/audit` +// specifically retains the historical Agent Actions preset. This drives the +// real route table so removing either registration fails loudly. import type { ReactNode } from "react"; import { flushSync } from "react-dom"; @@ -14,6 +13,12 @@ import { MemoryRouter, useLocation } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; +const streamlinedUiState = vi.hoisted(() => ({ enabled: true, loaded: true })); + +vi.mock("./hooks/useStreamlinedUiEnabled", () => ({ + useStreamlinedUiEnabled: () => streamlinedUiState, +})); + // jsdom's CSS parser rejects the custom-property marker rule stitches inserts // (`--sxs{--sxs:N}`), pulled into 's eager import graph transitively via // @codesandbox/sandpack-react. Substitute a benign, valid rule on parse failure @@ -48,6 +53,11 @@ vi.mock("./components/Layout", async () => { return { Layout: () => }; }); +vi.mock("./components/Layout.production", async () => { + const { Outlet } = await import("react-router-dom"); + return { Layout: () => }; +}); + // Rendered by outside and needs DialogProvider; irrelevant here. vi.mock("./components/OnboardingWizardVariant", () => ({ OnboardingWizardVariant: () => null, @@ -69,6 +79,13 @@ vi.mock("./pages/audit/CompanyActivity", () => ({ }, })); +vi.mock("./pages/audit/AuditHub", () => ({ + AuditHub: ({ section }: { section: string }) => { + const location = useLocation(); + return
{`AUDIT_${section.toUpperCase()}@${location.pathname}${location.search}`}
; + }, +})); + vi.mock("./pages/Issues", () => ({ Issues: () => { const location = useLocation(); @@ -76,6 +93,28 @@ vi.mock("./pages/Issues", () => ({ }, })); +vi.mock("./pages/audit/CompanyActivity.production", () => ({ + CompanyActivity: () => { + const location = useLocation(); + return
{`PRODUCTION_ACTIVITY@${location.pathname}${location.search}`}
; + }, +})); + +vi.mock("./pages/Costs.production", () => ({ + Costs: () => { + const location = useLocation(); + return
{`PRODUCTION_COSTS@${location.pathname}${location.search}`}
; + }, +})); + +vi.mock("./pages/NotFound", () => ({ + NotFoundPage: ({ scope }: { scope: string }) =>
{`NOT_FOUND:${scope}`}
, +})); + +vi.mock("./pages/PluginPage", () => ({ + PluginPage: () =>
PRODUCTION_PLUGIN_FALLBACK
, +})); + const PAP_COMPANY = { id: "company-1", name: "Paperclip", @@ -137,6 +176,8 @@ describe("App Activity routing (PAP-16302)", () => { container = document.createElement("div"); document.body.appendChild(container); companyState = { companies: [PAP_COMPANY], selected: PAP_COMPANY }; + streamlinedUiState.enabled = true; + streamlinedUiState.loaded = true; }); afterEach(() => { @@ -152,12 +193,45 @@ describe("App Activity routing (PAP-16302)", () => { flushSync(() => root.unmount()); }); + it("serves organization and entity-scoped run history beneath Activity", async () => { + const root = renderAppAt( + container, + "/PAP/activity/runs?entityType=routine&entityId=routine-1", + ); + await waitForRoute( + container, + "AUDIT_RUNS@/PAP/activity/runs?entityType=routine&entityId=routine-1", + ); + flushSync(() => root.unmount()); + }); + it("redirects /:company/audit to Activity with the agent-actions mode preset", async () => { const root = renderAppAt(container, "/PAP/audit"); await waitForRoute(container, "ACTIVITY_PAGE@/PAP/activity?mode=agents"); flushSync(() => root.unmount()); }); + it("serves each canonical Audit section under Activity", async () => { + const root = renderAppAt(container, "/PAP/activity/runs?agentId=agent-1"); + await waitForRoute(container, "AUDIT_RUNS@/PAP/activity/runs?agentId=agent-1"); + flushSync(() => root.unmount()); + }); + + it("redirects legacy costs and preserves its deep-link filters", async () => { + const root = renderAppAt(container, "/PAP/costs?range=30d#usage"); + await waitForRoute(container, "AUDIT_COSTS@/PAP/activity/costs?range=30d"); + flushSync(() => root.unmount()); + }); + + it("redirects legacy Audit sections to their canonical destination", async () => { + const root = renderAppAt(container, "/PAP/audit/budgets?projectId=project-1"); + await waitForRoute( + container, + "AUDIT_BUDGETS@/PAP/activity/budgets?projectId=project-1", + ); + flushSync(() => root.unmount()); + }); + it("keeps the company from the URL when /:company/audit is not the selected company", async () => { // A shared /ACME/audit link opened by someone whose selected company is PAP // must still show ACME's activity. The redirect target is written absolute @@ -186,4 +260,48 @@ describe("App Activity routing (PAP-16302)", () => { expect(container.textContent).not.toContain("/tasks/dashboard"); flushSync(() => root.unmount()); }); + + it("redirects a bare legacy section through to the prefixed Audit hub", async () => { + const root = renderAppAt(container, "/runs?runStatus=succeeded"); + await waitForRoute(container, "AUDIT_RUNS@/PAP/activity/runs?runStatus=succeeded"); + flushSync(() => root.unmount()); + }); + + it("uses the production Activity and Costs routes when Streamlined UI is disabled", async () => { + streamlinedUiState.enabled = false; + const activityRoot = renderAppAt(container, "/PAP/activity"); + await waitForRoute(container, "PRODUCTION_ACTIVITY@/PAP/activity"); + flushSync(() => activityRoot.unmount()); + + const costsRoot = renderAppAt(container, "/PAP/costs?range=30d"); + await waitForRoute(container, "PRODUCTION_COSTS@/PAP/costs?range=30d"); + flushSync(() => costsRoot.unmount()); + }); + + it("omits streamlined-only Audit routes when Streamlined UI is disabled", async () => { + streamlinedUiState.enabled = false; + const root = renderAppAt(container, "/PAP/activity/runs"); + await waitForRoute(container, "PRODUCTION_PLUGIN_FALLBACK"); + expect(container.textContent).not.toContain("AUDIT_RUNS"); + flushSync(() => root.unmount()); + }); + + it("does not mount streamlined routes before the experiment setting loads", async () => { + streamlinedUiState.loaded = false; + const root = renderAppAt(container, "/PAP/activity"); + expect(container.textContent).not.toContain("ACTIVITY_PAGE"); + expect(container.textContent).not.toContain("PRODUCTION_ACTIVITY"); + + streamlinedUiState.enabled = false; + streamlinedUiState.loaded = true; + flushSync(() => { + root.render( + + + , + ); + }); + await waitForRoute(container, "PRODUCTION_ACTIVITY@/PAP/activity"); + flushSync(() => root.unmount()); + }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 803188021cd..a75caa480ed 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,9 +1,10 @@ -import { lazy, Suspense } from "react"; +import { lazy, Suspense, type ReactNode } from "react"; import type { ToolConnectionCredentialSource } from "@paperclipai/shared"; import { Navigate, Outlet, Route, Routes, useActiveCompanyPrefix, useLocation, useParams } from "@/lib/router"; import { Button } from "@/components/ui/button"; import { useTranslation } from "@/i18n"; import { Layout } from "./components/Layout"; +import { Layout as ProductionLayout } from "./components/Layout.production"; import { ConferenceRoomChatGate } from "./components/ConferenceRoomChatGate"; import { TaskChatLab } from "./pages/TaskChatLab"; import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate"; @@ -48,8 +49,8 @@ import { Artifacts } from "./pages/Artifacts"; import { GoalDetail } from "./pages/GoalDetail"; import { Approvals } from "./pages/Approvals"; import { ApprovalDetail } from "./pages/ApprovalDetail"; -import { Costs } from "./pages/Costs"; import { CompanyActivity } from "./pages/audit/CompanyActivity"; +import { AuditHub } from "./pages/audit/AuditHub"; import { Inbox } from "./pages/Inbox"; import { WhatNeedsMe } from "./pages/WhatNeedsMe"; import { DecisionQueuePage } from "./pages/DecisionQueuePage"; @@ -84,7 +85,6 @@ import { PluginManager } from "./pages/PluginManager"; import { PluginSettings } from "./pages/PluginSettings"; import { AdapterManager } from "./pages/AdapterManager"; import { PluginPage } from "./pages/PluginPage"; -import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; @@ -102,6 +102,7 @@ import { } from "./lib/onboarding-route"; import { filterHiddenInstanceSettingsPath, normalizeRememberedInstanceSettingsPath } from "./lib/instance-settings"; import { useCloudInstance } from "./hooks/useCloudInstance"; +import { useStreamlinedUiEnabled } from "./hooks/useStreamlinedUiEnabled"; import { cloudStackCreateUrl } from "./lib/cloudLinks"; import { navigateTopLevel } from "@/lib/browserNavigation"; @@ -109,13 +110,45 @@ const CompanyExport = lazy(() => import("./pages/CompanyExport").then((module) => ({ default: module.CompanyExport })), ); -function boardRoutes() { +const ProductionAgents = lazy(() => + import("./pages/Agents.production").then((module) => ({ default: module.Agents })), +); +const ProductionAgentDetail = lazy(() => + import("./pages/AgentDetail.production").then((module) => ({ default: module.AgentDetail })), +); +const ProductionRoutines = lazy(() => + import("./pages/Routines.production").then((module) => ({ default: module.Routines })), +); +const ProductionRoutineDetail = lazy(() => + import("./pages/RoutineDetail.production").then((module) => ({ default: module.RoutineDetail })), +); +const ProductionCompanySkills = lazy(() => + import("./pages/CompanySkills.production").then((module) => ({ default: module.CompanySkills })), +); +const ProductionCompanyActivity = lazy(() => + import("./pages/audit/CompanyActivity.production").then((module) => ({ default: module.CompanyActivity })), +); +const ProductionCosts = lazy(() => + import("./pages/Costs.production").then((module) => ({ default: module.Costs })), +); +const ProductionOrgChart = lazy(() => + import("./pages/OrgChart.production").then((module) => ({ default: module.OrgChart })), +); + +function ProductionSurface({ children }: { children: ReactNode }) { + return }>{children}; +} + +function boardRoutes(streamlinedUiEnabled: boolean) { return ( <> } /> } /> } /> - } /> + : } + /> } /> } /> } /> @@ -213,19 +246,29 @@ function boardRoutes() { } /> } /> } /> - } /> + : } + /> } /> } /> } /> - } /> + : } + /> } /> {AGENT_FILTER_TABS.map((tab) => ( - } /> + : } + /> ))} } /> - } /> - } /> - } /> + : } /> + : } /> + : } /> } /> } /> } /> @@ -252,7 +295,7 @@ function boardRoutes() { {import.meta.env.DEV ? ( } /> ) : null} - } /> + : } /> } @@ -304,8 +347,8 @@ function boardRoutes() { path="pipelines/:pipelineId/cases/:caseId" element={} /> - } /> - } /> + : } /> + : } /> }> }> } /> @@ -323,11 +366,29 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> - {/* `/audit` merged into the single Activity page (PAP-16302). Existing deep - links keep working, preset to the agent-actions scope. */} - } /> + : } /> + {streamlinedUiEnabled ? ( + <> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) : ( + <> + } /> + } /> + + )} {/* Conference Room Chat surfaces (PAP-136/PAP-137): routes stay registered but redirect to the company home while the experimental flag is off. The board-level `artifacts` mount below is the new @@ -575,6 +636,20 @@ function StatusCardsLegacyRedirect() { return ; } +function AuditCompatibilityRedirect({ + to, + forceAgentMode = false, +}: { + to: string; + forceAgentMode?: boolean; +}) { + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + if (forceAgentMode) searchParams.set("mode", "agents"); + const search = searchParams.toString(); + return ; +} + function UnprefixedBoardRedirect() { const location = useLocation(); const { companies, selectedCompany, loading } = useCompany(); @@ -649,6 +724,8 @@ function NoCompaniesStartPage() { } export function App() { + const { enabled: streamlinedUiEnabled, loaded: streamlinedUiLoaded } = useStreamlinedUiEnabled(); + return ( <> @@ -661,7 +738,7 @@ export function App() { } /> } /> - }> + : }> } /> } /> } /> @@ -689,6 +766,16 @@ export function App() { } /> } /> } /> + {streamlinedUiEnabled ? ( + <> + } /> + } /> + } /> + } /> + } /> + } /> + + ) : null} } /> } /> } /> @@ -721,8 +808,8 @@ export function App() { } /> } /> } /> - }> - {boardRoutes()} + : }> + {boardRoutes(streamlinedUiEnabled)} } /> diff --git a/ui/src/components/AgentContextualSidebar.test.tsx b/ui/src/components/AgentContextualSidebar.test.tsx new file mode 100644 index 00000000000..f95068b2a36 --- /dev/null +++ b/ui/src/components/AgentContextualSidebar.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom + +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { AgentContextualSidebar } from "./AgentContextualSidebar"; + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); + +vi.mock("./ContextualSidebarFrame", () => ({ + ContextualSidebarFrame: ({ + title, + showHeader, + className, + children, + }: { + title: string; + showHeader?: boolean; + className?: string; + children: React.ReactNode; + }) => ( + + ), +})); + +vi.mock("./SidebarNavItem", () => ({ + SidebarNavItem: ({ to, label }: { to: string; label: string }) => {label}, +})); + +describe("AgentContextualSidebar", () => { + it("renders local definition/runtime/governance links and scoped Audit links", () => { + const queryClient = new QueryClient(); + const markup = renderToStaticMarkup( + + + + + , + ); + + expect(markup).toContain("Codex Coder"); + expect(markup).toContain('data-show-header="false"'); + expect(markup).toContain("border-r border-border bg-background"); + expect(markup).toContain('data-slot="contextual-sidebar-nav"'); + expect(markup).toContain('href="/agents/codexcoder/overview"'); + expect(markup).toContain('href="/agents/codexcoder/permissions"'); + expect(markup).toContain('href="/agents/codexcoder/api-keys"'); + expect(markup).toContain('href="/activity?mode=agents&agentId=agent-1"'); + expect(markup).toContain('href="/activity/runs?agentId=agent-1"'); + expect(markup).toContain("Harness / Runtime"); + expect(markup).toContain("Permissions / Trust"); + }); +}); diff --git a/ui/src/components/AgentContextualSidebar.tsx b/ui/src/components/AgentContextualSidebar.tsx new file mode 100644 index 00000000000..41b7489e666 --- /dev/null +++ b/ui/src/components/AgentContextualSidebar.tsx @@ -0,0 +1,131 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + BadgeDollarSign, + BookOpenText, + History, + KeyRound, + Library, + PlayCircle, + ReceiptText, + Settings2, + ShieldCheck, + Sparkles, + Wrench, +} from "lucide-react"; +import { agentsApi } from "@/api/agents"; +import { useCompany } from "@/context/CompanyContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { ContextualSidebarFrame } from "./ContextualSidebarFrame"; +import { SidebarNavItem } from "./SidebarNavItem"; +import { contextualSidebarStyles } from "./contextual-sidebar-styles"; +import { + AGENT_DETAIL_NAVIGATION, + agentDetailHref, + agentScopedAuditHref, + type AgentLocalDetailView, +} from "@/pages/agent-detail-navigation"; + +const localIcons = { + overview: Sparkles, + instructions: BookOpenText, + skills: Library, + runtime: Settings2, + secrets: ShieldCheck, + tools: Wrench, + permissions: ShieldCheck, + "api-keys": KeyRound, + revisions: History, +} satisfies Record; + +const auditItems = [ + { section: "activity", label: "Activity", icon: Activity }, + { section: "runs", label: "Runs", icon: PlayCircle }, + { section: "costs", label: "Costs", icon: ReceiptText }, + { section: "budgets", label: "Budgets", icon: BadgeDollarSign }, +] as const; + +export function AgentContextualSidebar({ + agentRef, + agentId, + agentName, +}: { + agentRef: string; + agentId?: string; + agentName?: string; +}) { + const { selectedCompanyId } = useCompany(); + const shouldResolveAgent = !agentId || !agentName; + const { data: resolvedAgent } = useQuery({ + queryKey: [...queryKeys.agents.detail(agentRef), selectedCompanyId ?? null, "contextual-sidebar"], + queryFn: () => agentsApi.get(agentRef, selectedCompanyId ?? undefined), + enabled: shouldResolveAgent && Boolean(agentRef && selectedCompanyId), + }); + const resolvedId = agentId ?? resolvedAgent?.id; + const resolvedName = agentName ?? resolvedAgent?.name ?? "Agent"; + + return ( + + + + ); +} diff --git a/ui/src/components/AppConnectionSidebar.production.tsx b/ui/src/components/AppConnectionSidebar.production.tsx new file mode 100644 index 00000000000..8c2cb02e663 --- /dev/null +++ b/ui/src/components/AppConnectionSidebar.production.tsx @@ -0,0 +1,154 @@ +import { ChevronLeft } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { humanizeConnectionDisplayName } from "@paperclipai/shared"; +import type { ToolApplication, ToolConnection } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { toolsApi } from "@/api/tools"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { + APP_TABS, + CONNECTED_ONLY_APP_TABS, + appApplicationTabHref, + appTabHref, + type AppTabKey, +} from "@/pages/apps/app-tabs"; +import { AppLogo } from "@/pages/apps/AppLogo"; +import { + appDefinitionLogoUrl, + appDefinitionName, + appDefinitionSlug, + type AppGalleryDisplayEntry, +} from "@/pages/apps/app-definition-display"; +import { SidebarNavItem } from "./SidebarNavItem.production"; + +type AppDetailSidebarProps = + | { kind: "connection"; connectionId: string } + | { kind: "application"; applicationId: string }; + +export function AppDetailSidebar(props: AppDetailSidebarProps) { + const { selectedCompanyId } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + + const connectionQuery = useQuery({ + queryKey: queryKeys.tools.connection(props.kind === "connection" ? props.connectionId : "__none__"), + queryFn: () => toolsApi.getConnection(props.kind === "connection" ? props.connectionId : ""), + enabled: props.kind === "connection" && !!props.connectionId, + }); + const applicationsQuery = useQuery({ + queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listApplications(selectedCompanyId!), + enabled: props.kind === "application" && !!selectedCompanyId, + }); + const connectionsQuery = useQuery({ + queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listConnections(selectedCompanyId!), + enabled: props.kind === "application" && !!selectedCompanyId, + }); + const galleryQuery = useQuery({ + queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listGallery(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const attentionQuery = useQuery({ + queryKey: queryKeys.apps.attention(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listAppsAttention(selectedCompanyId!), + enabled: !!selectedCompanyId, + refetchInterval: 30_000, + }); + + const connection = connectionQuery.data; + const application = props.kind === "application" + ? (applicationsQuery.data?.applications ?? []).find((app) => app.id === props.applicationId) + : null; + const appConnections = props.kind === "application" + ? (connectionsQuery.data?.connections ?? []).filter((candidate) => candidate.applicationId === props.applicationId) + : []; + const previousConnection = latestArchivedConnection(appConnections); + const appName = connection ? humanizeConnectionDisplayName(connection) : application?.name ?? "App"; + const logoEntry = galleryEntryFor( + (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], + connection, + application ?? undefined, + ); + const reviewConnectionId = connection?.id ?? previousConnection?.id ?? null; + const attentionItem = reviewConnectionId + ? attentionQuery.data?.apps.find((app) => app.connection.id === reviewConnectionId) + : null; + const reviewCount = + (attentionItem?.pendingActionRequestCount ?? 0) + + ((attentionItem?.quarantinedCatalogEntryCount ?? 0) > 0 ? 1 : 0); + + return ( + + ); +} + +function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string { + return props.kind === "connection" + ? appTabHref(props.connectionId, tab) + : appApplicationTabHref(props.applicationId, tab); +} + +function galleryEntryFor( + apps: AppGalleryDisplayEntry[], + connection: ToolConnection | undefined, + application: ToolApplication | undefined, +): AppGalleryDisplayEntry | null { + if (application?.applicationKey) { + const keyed = apps.find((app) => appDefinitionSlug(app) === application.applicationKey); + if (keyed) return keyed; + } + const name = (connection?.name ?? application?.name)?.toLowerCase(); + if (!name) return null; + return apps.find((app) => appDefinitionName(app).toLowerCase() === name) ?? + apps.find((app) => appDefinitionSlug(app) === name) ?? + null; +} + +function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null { + const archived = connections.filter((connection) => connection.status === "archived"); + if (archived.length === 0) return null; + return archived.reduce((latest, connection) => { + const latestTime = new Date(latest.updatedAt ?? latest.createdAt ?? 0).getTime(); + const connectionTime = new Date(connection.updatedAt ?? connection.createdAt ?? 0).getTime(); + return connectionTime > latestTime ? connection : latest; + }); +} diff --git a/ui/src/components/AppsSidebar.production.tsx b/ui/src/components/AppsSidebar.production.tsx new file mode 100644 index 00000000000..d3f09d355dc --- /dev/null +++ b/ui/src/components/AppsSidebar.production.tsx @@ -0,0 +1,92 @@ +import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react"; +import { Link } from "@/lib/router"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs"; +import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled"; +import { useReviewCount } from "@/pages/apps/useReviewCount"; +import { SidebarNavItem } from "./SidebarNavItem.production"; + +/** + * Secondary sidebar for the prosumer Apps area (PAP-10856; three-door IA + * PAP-13254 / U3). + * + * ← Back · APPS: Browse / Review (n) + * DEVELOPER: Connections / Gateways / Profiles / Rules / Health / Activity + * + * "Browse" is the store and "Review" holds decisions waiting on the user's + * OK. Connection management lives with the Developer tools. + * "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 + * 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). + */ +export function AppsSidebar() { + const { selectedCompany } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + + const reviewCount = useReviewCount(); + const { enabled: smokeLabEnabled } = useSmokeLabEnabled(); + const developerTabs = DEVELOPER_TABS.filter( + (tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled, + ); + + return ( + + ); +} diff --git a/ui/src/components/AppsSidebar.test.tsx b/ui/src/components/AppsSidebar.test.tsx index 84ea1cabf78..2f5c5fc29db 100644 --- a/ui/src/components/AppsSidebar.test.tsx +++ b/ui/src/components/AppsSidebar.test.tsx @@ -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 { contextualSidebarStyles } from "./contextual-sidebar-styles"; const sidebarNavItemMock = vi.hoisted(() => vi.fn()); const mockToolsApi = vi.hoisted(() => ({ @@ -97,7 +98,7 @@ describe("AppsSidebar", () => { vi.clearAllMocks(); }); - it("renders the consolidated connector and review doors without retired developer links", async () => { + it("renders the consolidated connector and review doors without a redundant contextual heading", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -112,7 +113,7 @@ describe("AppsSidebar", () => { }); await flushReact(); - expect(container.textContent).toContain("Apps"); + expect(container.textContent).not.toContain("Apps"); expect(container.textContent).not.toContain("Developer"); expect(container.textContent).not.toContain("Advanced setup for developers"); expect(container.textContent).not.toContain("Most teams"); @@ -152,6 +153,14 @@ describe("AppsSidebar", () => { expect(sidebarNavItemMock).not.toHaveBeenCalledWith( expect.objectContaining({ to: "/apps/advanced/audit" }), ); + expect(container.querySelector('[data-slot="contextual-sidebar-nav"]')?.className).toBe( + contextualSidebarStyles.nav, + ); + expect( + Array.from(container.querySelectorAll('[data-slot="contextual-sidebar-group"]')).every( + (group) => group.className === contextualSidebarStyles.group, + ), + ).toBe(true); await act(async () => { root.unmount(); diff --git a/ui/src/components/AppsSidebar.tsx b/ui/src/components/AppsSidebar.tsx index 4e48641984f..269eed56902 100644 --- a/ui/src/components/AppsSidebar.tsx +++ b/ui/src/components/AppsSidebar.tsx @@ -1,25 +1,18 @@ -import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react"; -import { Link } from "@/lib/router"; -import { useCompany } from "@/context/CompanyContext"; -import { useSidebar } from "@/context/SidebarContext"; +import { Store, ShieldQuestion } from "lucide-react"; import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs"; import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled"; import { useReviewCount } from "@/pages/apps/useReviewCount"; import { SidebarNavItem } from "./SidebarNavItem"; +import { contextualSidebarStyles } from "./contextual-sidebar-styles"; /** * Secondary sidebar for the Apps area. * - * ← Back · APPS: Connectors / Review (n) - * - * The landing page combines connector discovery, account management, and - * connection health. Review keeps governed actions waiting on the user's OK. - * Advanced developer surfaces remain hidden unless one is explicitly enabled. + * Connectors combines discovery, account management, and connection health. + * Review keeps governed actions waiting on the user's approval. Advanced + * developer surfaces remain hidden unless one is explicitly enabled. */ export function AppsSidebar() { - const { selectedCompany } = useCompany(); - const { isMobile, setSidebarOpen } = useSidebar(); - const reviewCount = useReviewCount(); const { enabled: smokeLabEnabled } = useSmokeLabEnabled(); const developerTabs = DEVELOPER_TABS.filter((tab) => { @@ -31,28 +24,12 @@ export function AppsSidebar() { return ( diff --git a/ui/src/components/BlockedInboxView.test.tsx b/ui/src/components/BlockedInboxView.test.tsx index 9bd971fe4ea..5b02970e854 100644 --- a/ui/src/components/BlockedInboxView.test.tsx +++ b/ui/src/components/BlockedInboxView.test.tsx @@ -164,6 +164,7 @@ describe("BlockedInboxView", () => { const { root } = renderWithClient( , container, ); @@ -258,6 +259,7 @@ describe("BlockedInboxView", () => { const { root } = renderWithClient( , container, ); @@ -269,6 +271,40 @@ describe("BlockedInboxView", () => { expect(rowText.indexOf("Board")).toBeGreaterThan(rowText.indexOf("Needs decision")); expect(rowText).not.toContain("Accept or reject"); expect(container.querySelector('[data-testid="blocked-row-reason-column"]')?.textContent).toContain("Needs decision"); + const taskRow = container.querySelector('[data-slot="task-row"]'); + const identifier = container.querySelector('[data-slot="task-row-identifier"]'); + const timestamp = container.querySelector('[data-slot="task-row-timestamp"]'); + expect(taskRow).not.toBeNull(); + expect(taskRow?.className).not.toContain("border-b"); + expect(identifier).not.toBeNull(); + expect(timestamp).not.toBeNull(); + if (!identifier || !timestamp) throw new Error("Expected canonical identifier and timestamp columns"); + expect(identifier.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + + act(() => root.unmount()); + }); + + it("restores the legacy status/id prefix, timestamp column, and row divider", async () => { + mockIssuesApi.list.mockResolvedValue([ + makeIssue( + "issue-legacy", + "PAP-41", + "Legacy blocked row", + attention({ owner: { type: "board", agentId: null, userId: null, label: "Board" } }), + ), + ]); + + const { root } = renderWithClient( + , + container, + ); + await waitFor(() => container.textContent?.includes("Legacy blocked row") === true); + + expect(container.querySelector('[data-slot="task-row"]')).toBeNull(); + expect(container.querySelector('[data-testid="blocked-row-age"]')).not.toBeNull(); + const row = container.querySelector("a")?.parentElement; + expect(row?.className).toContain("border-b"); + expect(row?.textContent).toContain("PAP-41"); act(() => root.unmount()); }); diff --git a/ui/src/components/BlockedInboxView.tsx b/ui/src/components/BlockedInboxView.tsx index 1880fe2e0c8..c858655e1dc 100644 --- a/ui/src/components/BlockedInboxView.tsx +++ b/ui/src/components/BlockedInboxView.tsx @@ -19,7 +19,7 @@ import { } from "../lib/blockedInbox"; import { BlockedReasonChip } from "./BlockedReasonChip"; import { IssueGroupHeader } from "./IssueGroupHeader"; -import { IssueRow } from "./IssueRow"; +import { IssueRow, type IssueRowPresentation } from "./IssueRow"; import { Identity } from "./Identity"; import { StatusIcon } from "./StatusIcon"; import { Button } from "@/components/ui/button"; @@ -41,6 +41,7 @@ interface BlockedInboxViewProps { showStatusColumn: boolean; showIdentifierColumn: boolean; showUpdatedColumn: boolean; + presentation?: IssueRowPresentation; } const BLOCKED_LIST_LIMIT = 200; @@ -61,6 +62,7 @@ export function BlockedInboxView({ showStatusColumn, showIdentifierColumn, showUpdatedColumn, + presentation = "legacy", }: BlockedInboxViewProps) { const [collapsedVariants, setCollapsedVariants] = useState>(() => new Set()); @@ -124,7 +126,10 @@ export function BlockedInboxView({ {Array.from({ length: 2 }).map((__, rowIdx) => (
@@ -219,6 +224,7 @@ export function BlockedInboxView({ showStatusColumn={showStatusColumn} showIdentifierColumn={showIdentifierColumn} showUpdatedColumn={showUpdatedColumn} + presentation={presentation} /> )) ) : ( @@ -226,7 +232,7 @@ export function BlockedInboxView({ const isCollapsed = collapsedVariants.has(group.variant); return (
-
+
))}
@@ -271,6 +278,7 @@ interface BlockedInboxRowProps { showStatusColumn: boolean; showIdentifierColumn: boolean; showUpdatedColumn: boolean; + presentation: IssueRowPresentation; } function resolveOwnerName( @@ -299,6 +307,7 @@ function BlockedInboxRow({ showStatusColumn, showIdentifierColumn, showUpdatedColumn, + presentation, }: BlockedInboxRowProps) { const { label: ownerName, isAgent } = resolveOwnerName(row, agentNameById, userLabelById); const stoppedAge = formatStoppedAge(row.attention.stoppedSinceAt); @@ -330,7 +339,7 @@ function BlockedInboxRow({ ) : (