diff --git a/tests/perf/issue-detail/README.md b/tests/perf/issue-detail/README.md index 6e29177b36d..687fdc3ab35 100644 --- a/tests/perf/issue-detail/README.md +++ b/tests/perf/issue-detail/README.md @@ -26,6 +26,20 @@ PAPERCLIP_ISSUE_PERF_RUNS=7 PAPERCLIP_ISSUE_PERF_PORT=3210 pnpm exec playwright Outputs include `baseline.md`, `baseline.json`, and a Chrome trace for the first run of each scenario/profile. Open `*.trace.json` in Chrome DevTools Performance to inspect the `issue-detail:*` user-timing marks. +For bundle comparisons, build the UI and serve the production assets from an +isolated local instance with `PAPERCLIP_UI_DEV_MIDDLEWARE=false`. The default +managed server above uses development middleware, which does not reproduce +production chunk loading. Keep the server, database, browser profile and sample +count the same for both builds, and avoid concurrent builds or test suites while +measuring latency. + +Copy the first run's output outside `test-results/issue-detail-perf`, then reuse +its persisted task with `PAPERCLIP_ISSUE_PERF_SEED_FILE=/path/to/before/baseline.json` +and `PAPERCLIP_ISSUE_PERF_BASE_URL` pointing to that disposable instance. The +harness validates the task identity before measuring. Network spans in the trace +use recorded request start/end timestamps relative to task navigation; they +include completed requests observed during the sample, even after content paint. + ## Task layout stability `layout-stability.spec.ts` exercises the real task route, including Inbox diff --git a/tests/perf/issue-detail/issue-detail.perf.spec.ts b/tests/perf/issue-detail/issue-detail.perf.spec.ts index 66cc8df968c..f8ffb4b2d9f 100644 --- a/tests/perf/issue-detail/issue-detail.perf.spec.ts +++ b/tests/perf/issue-detail/issue-detail.perf.spec.ts @@ -27,6 +27,7 @@ type NetworkRecord = { status?: number; responseHeaders?: Record; ttfbMs?: number; + startedAtEpochMs: number; completedAtEpochMs?: number; }; @@ -155,6 +156,7 @@ async function startNetworkCapture(session: Awaited { @@ -178,20 +180,20 @@ async function startNetworkCapture(session: Awaited) { +async function writeTrace(tracePath: string, metrics: RunMetrics, records: Map, navigationEpochMs: number) { await fs.mkdir(path.dirname(tracePath), { recursive: true }); const traceEvents = [ { name: "issue-detail:navigate", cat: "blink.user_timing", ph: "i", ts: 0, pid: 1, tid: 1, s: "t" }, { name: HEADER_MEASURE, cat: "blink.user_timing", ph: "X", ts: 0, dur: metrics.headerPaintMs * 1000, pid: 1, tid: 1 }, { name: CONTENT_MEASURE, cat: "blink.user_timing", ph: "X", ts: 0, dur: metrics.contentPaintMs * 1000, pid: 1, tid: 1 }, - ...[...records.values()].map((record, index) => ({ + ...[...records.values()].filter((record) => record.completedAtEpochMs !== undefined).map((record) => ({ name: record.url, cat: "loading", - ph: "i", - ts: index + 1, + ph: "X", + ts: (record.startedAtEpochMs - navigationEpochMs) * 1000, + dur: (record.completedAtEpochMs! - record.startedAtEpochMs) * 1000, pid: 1, tid: 2, - s: "t", args: { method: record.method, type: record.type, bytes: record.encodedDataLength, status: record.status }, })), ]; @@ -258,7 +260,7 @@ async function runScenario(browser: Browser, baseURL: string, seedData: Seed, pr await page.goto(`/${seedData.prefix}/issues/${seedData.identifier}`); } - await expect(page.getByTestId("issue-detail-header")).toBeVisible({ timeout: PAGE_READY_TIMEOUT_MS }); + await expect(page.getByTestId("issue-detail-header").filter({ visible: true }).first()).toBeVisible({ timeout: PAGE_READY_TIMEOUT_MS }); const paint = await readPaintMetrics(page); const summary = summarizeNetwork(network, seedData, paint.contentPaintEpochMs); const { contentPaintEpochMs: _contentPaintEpochMs, ...reportedPaint } = paint; @@ -266,7 +268,7 @@ async function runScenario(browser: Browser, baseURL: string, seedData: Seed, pr ? { ...reportedPaint, ttfbMs: null, fcpMs: null, lcpMs: null } : reportedPaint; const metrics = { scenario, profile: profile.name, run, ...scenarioPaint, ...summary }; - if (run === 1) await writeTrace(tracePath, metrics, network); + if (run === 1) await writeTrace(tracePath, metrics, network, paint.contentPaintEpochMs - paint.contentPaintMs); if (scenario.startsWith("S2") && profile.name === "unthrottled" && run === 1) { await page.screenshot({ path: path.join(OUTPUT_DIR, "issue-detail-loaded.png"), fullPage: true }); } @@ -322,7 +324,19 @@ function buildMarkdown(results: RunMetrics[]): string { test("issue-detail baseline", async ({ request, baseURL }) => { expect(baseURL).toBeTruthy(); await fs.mkdir(OUTPUT_DIR, { recursive: true }); - const seedData = await seed(request); + const seedFile = process.env.PAPERCLIP_ISSUE_PERF_SEED_FILE; + const seedData: Seed = seedFile + ? JSON.parse(await fs.readFile(seedFile, "utf8")).seed + : await seed(request); + if (seedFile) { + const response = await request.get(`/api/issues/${seedData.issueId}`); + expect(response.ok(), await response.text()).toBe(true); + expect(await response.json()).toMatchObject({ + companyId: seedData.companyId, + identifier: seedData.identifier, + title: seedData.title, + }); + } const results: RunMetrics[] = []; for (const profile of PROFILES) { diff --git a/tests/perf/issue-detail/layout-stability.spec.ts b/tests/perf/issue-detail/layout-stability.spec.ts index 5c06655b53e..f54083fcf19 100644 --- a/tests/perf/issue-detail/layout-stability.spec.ts +++ b/tests/perf/issue-detail/layout-stability.spec.ts @@ -36,7 +36,7 @@ test.beforeAll(async ({ request }) => { }); async function ready(page: Page) { - await expect(page.getByTestId("task-chat-thread").locator('[aria-busy="false"]')).toBeVisible({ timeout: 90_000 }); + await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 90_000 }); } async function installMeasurements(page: Page) { @@ -48,7 +48,7 @@ async function installMeasurements(page: Page) { }).observe({ type: "layout-shift", buffered: true }); const sample = () => { const scroller = document.querySelector('[data-testid="task-chat-scroller"]'); - const busy = document.querySelector('[data-testid="task-chat-thread"] [aria-busy]'); + const busy = document.querySelector('[data-testid="task-chat-thread"] > [aria-busy]'); if (scroller) { const viewport = scroller.getBoundingClientRect(); const row = [...scroller.querySelectorAll("[data-thread-anchor]")].find((el) => el.getBoundingClientRect().bottom > viewport.top); @@ -78,7 +78,7 @@ test("Inbox click reveals once after reordered initial responses; refresh retain const held = new Promise((resolve) => { release = resolve; }); await page.route("**/api/issues/*/comments?*", async (route) => { await held; await route.continue(); }); await link.click(); - await expect(page.getByTestId("task-chat-thread").locator('[aria-busy="true"]')).toBeVisible(); + await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="true"]')).toBeVisible(); await expect(page.getByText("Historical message 159", { exact: true })).not.toBeVisible(); release(); await ready(page); diff --git a/tests/runner-e2e/user-actions.ts b/tests/runner-e2e/user-actions.ts index 42965012c3f..bba252f098d 100644 --- a/tests/runner-e2e/user-actions.ts +++ b/tests/runner-e2e/user-actions.ts @@ -61,9 +61,12 @@ export async function createTaskThroughUi(input: { } await newTask.click(); const dialog = input.page.getByRole("dialog", { name: "New task", exact: true }); + await expect(dialog).toBeVisible(); const titleInput = dialog.getByRole("textbox", { name: "Task title", exact: true }); - if (await titleInput.isVisible()) await titleInput.fill(input.title); - else if (input.requireExplicitTitle) throw new Error("This title-preservation case requires a visible explicit title input"); + if (input.requireExplicitTitle) { + await expect(titleInput).toBeVisible(); + await titleInput.fill(input.title); + } else if (await titleInput.isVisible()) await titleInput.fill(input.title); await fillTaskPrompt(dialog.getByRole("textbox", { name: "editable markdown", exact: true }), input.prompt); if (input.workMode !== "standard") { await dialog.getByRole("button", { name: "Add to composer", exact: true }).click(); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d7a3faacefd..b9286cf717d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,12 +1,11 @@ -import { lazy, Suspense, type ReactNode } from "react"; +import { AgentChats } from "./pages/AgentChats"; +import { lazy, Suspense, useEffect, 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 { CasesExperimentalGate } from "./components/CasesExperimentalGate"; import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimentalGate"; import { CloudManagedPageGate } from "./components/CloudManagedPageGate"; @@ -17,91 +16,14 @@ import { UnprefixedExecutionWorkspaceRedirect, } from "./components/UnprefixedExecutionWorkspaceRedirect"; import { useHiddenSettings } from "./hooks/useHiddenSettings"; -import { Cases } from "./pages/Cases"; -import { CaseDetail } from "./pages/CaseDetail"; import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant"; import { CloudAccessGate } from "./components/CloudAccessGate"; import { PaperclipLoading } from "./components/AnimatedPaperclipIcon"; -import { Dashboard } from "./pages/Dashboard"; -import { DashboardLive } from "./pages/DashboardLive"; -import { Timeline } from "./pages/Timeline"; -import { Companies } from "./pages/Companies"; -import { AGENT_FILTER_TABS, Agents } from "./pages/Agents"; -import { AgentDetail } from "./pages/AgentDetail"; -import { Projects } from "./pages/Projects"; -import { ProjectDetail } from "./pages/ProjectDetail"; -import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; -import { Workspaces } from "./pages/Workspaces"; -import { Issues } from "./pages/Issues"; -import { Search } from "./pages/Search"; -import { IssueDetail } from "./pages/IssueDetail"; -import { AgentChats } from "./pages/AgentChats"; -import { AgentChat } from "./pages/AgentChat"; -import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf"; -import { Routines } from "./pages/Routines"; -import { StatusCards } from "./pages/StatusCards"; -import { RoutineDetail } from "./pages/RoutineDetail"; -import { UserProfile } from "./pages/UserProfile"; -import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; -import { Goals } from "./pages/Goals"; -import { Artifacts } from "./pages/Artifacts"; -import { GoalDetail } from "./pages/GoalDetail"; -import { Approvals } from "./pages/Approvals"; -import { ApprovalDetail } from "./pages/ApprovalDetail"; -import { CompanyActivity } from "./pages/audit/CompanyActivity"; -import { AuditHub } from "./pages/audit/AuditHub"; -import { Costs } from "./pages/Costs"; -import { Inbox } from "./pages/Inbox"; +import { AGENT_FILTER_TABS } from "./lib/agent-filter-tabs"; import { useCombinedInboxTasksEnabled } from "./hooks/useCombinedInboxTasksEnabled"; -import { WhatNeedsMe } from "./pages/WhatNeedsMe"; -import { DecisionQueuePage } from "./pages/DecisionQueuePage"; -import { BoardChat } from "./pages/BoardChat"; -import { CompanySettings } from "./pages/CompanySettings"; -import { CompanyConnections } from "./pages/CompanyConnections"; -import { CompanyEnvironments } from "./pages/CompanyEnvironments"; -import { BootstrapSetupUxLab } from "./pages/BootstrapSetupUxLab"; -import { ResponsibleUserDenialUxLab } from "./pages/ResponsibleUserDenialUxLab"; -import { CrossIssueCollaborationUxLab } from "./pages/CrossIssueCollaborationUxLab"; -import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; -import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; -import { AdvancedToolsRoute } from "./pages/tools/AdvancedToolsRoute"; -import { ProfileWizardRoute } from "./pages/tools/profiles/ProfileWizardRoute"; -import { ProfileDetailRoute } from "./pages/tools/profiles/ProfileDetailRoute"; -import { Browse } from "./pages/apps/Browse"; -import { AppsConnect } from "./pages/apps/AppsConnect"; -import { ChatEndpointSetup } from "./pages/apps/chat/ChatEndpointSetup"; -import { ChatEndpointDetail } from "./pages/apps/chat/ChatEndpointDetail"; -import { ChatIdentityConfirm } from "./pages/apps/chat/ChatIdentityConfirm"; import { ChatConnectorsExperimentalGate } from "./components/ChatConnectorsExperimentalGate"; import { useChatConnectorsEnabled } from "./hooks/useChatConnectorsEnabled"; import { canEnterAppsConnect } from "./pages/apps/app-connect-policy"; -import { AppsReview } from "./pages/apps/AppsReview"; -import { AppDetail } from "./pages/apps/AppDetail"; -import { AppNotConnected } from "./pages/apps/AppNotConnected"; -import { PaperclipCloudOAuthHandoffPage } from "./pages/apps/PaperclipCloudOAuthHandoff"; -import { GatewaysList } from "./pages/apps/gateways/GatewaysList"; -import { GatewayDetail } from "./pages/apps/gateways/GatewayDetail"; -import { CompanySkills } from "./pages/CompanySkills"; -import { SkillSources } from "./pages/SkillSources"; -import { SkillStudio } from "./pages/SkillStudio"; -import { Secrets } from "./pages/Secrets"; -import { CompanyImport } from "./pages/CompanyImport"; -import { DesignGuide } from "./pages/DesignGuide"; -import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings"; -import { InstanceAccess } from "./pages/InstanceAccess"; -import { ProfileSettings } from "./pages/ProfileSettings"; -import { PluginManager } from "./pages/PluginManager"; -import { PluginSettings } from "./pages/PluginSettings"; -import { AdapterManager } from "./pages/AdapterManager"; -import { PluginPage } from "./pages/PluginPage"; -import { NewAgent } from "./pages/NewAgent"; -import { AuthPage } from "./pages/Auth"; -import { BoardClaimPage } from "./pages/BoardClaim"; -import { AssistantConnection } from "./pages/apps/AssistantConnection"; -import { McpConnectPage, McpDevicePage, AssistantConnectionsPage } from "./pages/McpConnect"; -import { CliAuthPage } from "./pages/CliAuth"; -import { InviteLandingPage } from "./pages/InviteLanding"; -import { JoinRequestQueue } from "./pages/JoinRequestQueue"; import { NotFoundPage } from "./pages/NotFound"; import { useCompany } from "./context/CompanyContext"; import { useDialogActions, useDialogState } from "./context/DialogContext"; @@ -118,28 +40,99 @@ import { useStreamlinedUiEnabled } from "./hooks/useStreamlinedUiEnabled"; import { cloudStackCreateUrl } from "./lib/cloudLinks"; import { navigateTopLevel } from "@/lib/browserNavigation"; -const CompanyExport = lazy(() => - import("./pages/CompanyExport").then((module) => ({ default: module.CompanyExport })), -); +const AuthPage = lazy(() => import("./pages/Auth").then((module) => ({ default: module.AuthPage }))); +const BoardClaimPage = lazy(() => import("./pages/BoardClaim").then((module) => ({ default: module.BoardClaimPage }))); +const CliAuthPage = lazy(() => import("./pages/CliAuth").then((module) => ({ default: module.CliAuthPage }))); +const InviteLandingPage = lazy(() => import("./pages/InviteLanding").then((module) => ({ default: module.InviteLandingPage }))); +const ChatIdentityConfirm = lazy(() => import("./pages/apps/chat/ChatIdentityConfirm").then((module) => ({ default: module.ChatIdentityConfirm }))); +const PaperclipCloudOAuthHandoffPage = lazy(() => import("./pages/apps/PaperclipCloudOAuthHandoff").then((module) => ({ default: module.PaperclipCloudOAuthHandoffPage }))); +const McpConnectPage = lazy(() => import("./pages/McpConnect").then((module) => ({ default: module.McpConnectPage }))); +const McpDevicePage = lazy(() => import("./pages/McpConnect").then((module) => ({ default: module.McpDevicePage }))); +const AssistantConnectionsPage = lazy(() => import("./pages/McpConnect").then((module) => ({ default: module.AssistantConnectionsPage }))); +const ProductionLayout = lazy(() => import("./components/Layout.production").then((module) => ({ default: module.Layout }))); -const ProductionAgents = lazy(() => - import("./pages/Agents.production").then((module) => ({ default: module.Agents })), -); -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 ProductionOrgChart = lazy(() => - import("./pages/OrgChart.production").then((module) => ({ default: module.OrgChart })), -); +const TaskChatLab = lazy(() => import("./pages/TaskChatLab").then((module) => ({ default: module.TaskChatLab }))); +const Issues = lazy(() => import("./pages/Issues").then((module) => ({ default: module.Issues }))); +const loadIssueDetail = () => import("./pages/IssueDetail").then((module) => ({ default: module.IssueDetail })); +const IssueDetail = lazy(loadIssueDetail); +const AgentChat = lazy(() => import("./pages/AgentChat").then((module) => ({ default: module.AgentChat }))); +const IssueChatLongThreadPerf = lazy(() => import("./pages/IssueChatLongThreadPerf").then((module) => ({ default: module.IssueChatLongThreadPerf }))); +const Inbox = lazy(() => import("./pages/Inbox").then((module) => ({ default: module.Inbox }))); +const BootstrapSetupUxLab = lazy(() => import("./pages/BootstrapSetupUxLab").then((module) => ({ default: module.BootstrapSetupUxLab }))); +const ResponsibleUserDenialUxLab = lazy(() => import("./pages/ResponsibleUserDenialUxLab").then((module) => ({ default: module.ResponsibleUserDenialUxLab }))); +const CrossIssueCollaborationUxLab = lazy(() => import("./pages/CrossIssueCollaborationUxLab").then((module) => ({ default: module.CrossIssueCollaborationUxLab }))); +const Agents = lazy(() => import("./pages/Agents").then((module) => ({ default: module.Agents }))); +const Cases = lazy(() => import("./pages/Cases").then((module) => ({ default: module.Cases }))); +const CaseDetail = lazy(() => import("./pages/CaseDetail").then((module) => ({ default: module.CaseDetail }))); +const Dashboard = lazy(() => import("./pages/Dashboard").then((module) => ({ default: module.Dashboard }))); +const DashboardLive = lazy(() => import("./pages/DashboardLive").then((module) => ({ default: module.DashboardLive }))); +const Timeline = lazy(() => import("./pages/Timeline").then((module) => ({ default: module.Timeline }))); +const Companies = lazy(() => import("./pages/Companies").then((module) => ({ default: module.Companies }))); +const AgentDetail = lazy(() => import("./pages/AgentDetail").then((module) => ({ default: module.AgentDetail }))); +const Projects = lazy(() => import("./pages/Projects").then((module) => ({ default: module.Projects }))); +const ProjectDetail = lazy(() => import("./pages/ProjectDetail").then((module) => ({ default: module.ProjectDetail }))); +const ProjectWorkspaceDetail = lazy(() => import("./pages/ProjectWorkspaceDetail").then((module) => ({ default: module.ProjectWorkspaceDetail }))); +const Workspaces = lazy(() => import("./pages/Workspaces").then((module) => ({ default: module.Workspaces }))); +const Search = lazy(() => import("./pages/Search").then((module) => ({ default: module.Search }))); +const Routines = lazy(() => import("./pages/Routines").then((module) => ({ default: module.Routines }))); +const StatusCards = lazy(() => import("./pages/StatusCards").then((module) => ({ default: module.StatusCards }))); +const RoutineDetail = lazy(() => import("./pages/RoutineDetail").then((module) => ({ default: module.RoutineDetail }))); +const UserProfile = lazy(() => import("./pages/UserProfile").then((module) => ({ default: module.UserProfile }))); +const ExecutionWorkspaceDetail = lazy(() => import("./pages/ExecutionWorkspaceDetail").then((module) => ({ default: module.ExecutionWorkspaceDetail }))); +const Goals = lazy(() => import("./pages/Goals").then((module) => ({ default: module.Goals }))); +const Artifacts = lazy(() => import("./pages/Artifacts").then((module) => ({ default: module.Artifacts }))); +const GoalDetail = lazy(() => import("./pages/GoalDetail").then((module) => ({ default: module.GoalDetail }))); +const Approvals = lazy(() => import("./pages/Approvals").then((module) => ({ default: module.Approvals }))); +const ApprovalDetail = lazy(() => import("./pages/ApprovalDetail").then((module) => ({ default: module.ApprovalDetail }))); +const CompanyActivity = lazy(() => import("./pages/audit/CompanyActivity").then((module) => ({ default: module.CompanyActivity }))); +const AuditHub = lazy(() => import("./pages/audit/AuditHub").then((module) => ({ default: module.AuditHub }))); +const Costs = lazy(() => import("./pages/Costs").then((module) => ({ default: module.Costs }))); +const WhatNeedsMe = lazy(() => import("./pages/WhatNeedsMe").then((module) => ({ default: module.WhatNeedsMe }))); +const DecisionQueuePage = lazy(() => import("./pages/DecisionQueuePage").then((module) => ({ default: module.DecisionQueuePage }))); +const BoardChat = lazy(() => import("./pages/BoardChat").then((module) => ({ default: module.BoardChat }))); +const CompanySettings = lazy(() => import("./pages/CompanySettings").then((module) => ({ default: module.CompanySettings }))); +const CompanyConnections = lazy(() => import("./pages/CompanyConnections").then((module) => ({ default: module.CompanyConnections }))); +const CompanyEnvironments = lazy(() => import("./pages/CompanyEnvironments").then((module) => ({ default: module.CompanyEnvironments }))); +const CompanySettingsPluginPage = lazy(() => import("./pages/CompanySettingsPluginPage").then((module) => ({ default: module.CompanySettingsPluginPage }))); +const CompanyAccess = lazy(() => import("./pages/CompanyAccess").then((module) => ({ default: module.CompanyAccess }))); +const CompanyAccessLegacyRoute = lazy(() => import("./pages/CompanyAccess").then((module) => ({ default: module.CompanyAccessLegacyRoute }))); +const AdvancedToolsRoute = lazy(() => import("./pages/tools/AdvancedToolsRoute").then((module) => ({ default: module.AdvancedToolsRoute }))); +const ProfileWizardRoute = lazy(() => import("./pages/tools/profiles/ProfileWizardRoute").then((module) => ({ default: module.ProfileWizardRoute }))); +const ProfileDetailRoute = lazy(() => import("./pages/tools/profiles/ProfileDetailRoute").then((module) => ({ default: module.ProfileDetailRoute }))); +const Browse = lazy(() => import("./pages/apps/Browse").then((module) => ({ default: module.Browse }))); +const AppsConnect = lazy(() => import("./pages/apps/AppsConnect").then((module) => ({ default: module.AppsConnect }))); +const ChatEndpointSetup = lazy(() => import("./pages/apps/chat/ChatEndpointSetup").then((module) => ({ default: module.ChatEndpointSetup }))); +const ChatEndpointDetail = lazy(() => import("./pages/apps/chat/ChatEndpointDetail").then((module) => ({ default: module.ChatEndpointDetail }))); +const AppsReview = lazy(() => import("./pages/apps/AppsReview").then((module) => ({ default: module.AppsReview }))); +const AppDetail = lazy(() => import("./pages/apps/AppDetail").then((module) => ({ default: module.AppDetail }))); +const AppNotConnected = lazy(() => import("./pages/apps/AppNotConnected").then((module) => ({ default: module.AppNotConnected }))); +const GatewaysList = lazy(() => import("./pages/apps/gateways/GatewaysList").then((module) => ({ default: module.GatewaysList }))); +const GatewayDetail = lazy(() => import("./pages/apps/gateways/GatewayDetail").then((module) => ({ default: module.GatewayDetail }))); +const CompanySkills = lazy(() => import("./pages/CompanySkills").then((module) => ({ default: module.CompanySkills }))); +const SkillSources = lazy(() => import("./pages/SkillSources").then((module) => ({ default: module.SkillSources }))); +const SkillStudio = lazy(() => import("./pages/SkillStudio").then((module) => ({ default: module.SkillStudio }))); +const Secrets = lazy(() => import("./pages/Secrets").then((module) => ({ default: module.Secrets }))); +const CompanyImport = lazy(() => import("./pages/CompanyImport").then((module) => ({ default: module.CompanyImport }))); +const DesignGuide = lazy(() => import("./pages/DesignGuide").then((module) => ({ default: module.DesignGuide }))); +const InstanceExperimentalSettings = lazy(() => import("./pages/InstanceExperimentalSettings").then((module) => ({ default: module.InstanceExperimentalSettings }))); +const InstanceAccess = lazy(() => import("./pages/InstanceAccess").then((module) => ({ default: module.InstanceAccess }))); +const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then((module) => ({ default: module.ProfileSettings }))); +const PluginManager = lazy(() => import("./pages/PluginManager").then((module) => ({ default: module.PluginManager }))); +const PluginSettings = lazy(() => import("./pages/PluginSettings").then((module) => ({ default: module.PluginSettings }))); +const AdapterManager = lazy(() => import("./pages/AdapterManager").then((module) => ({ default: module.AdapterManager }))); +const PluginPage = lazy(() => import("./pages/PluginPage").then((module) => ({ default: module.PluginPage }))); +const NewAgent = lazy(() => import("./pages/NewAgent").then((module) => ({ default: module.NewAgent }))); +const AssistantConnection = lazy(() => import("./pages/apps/AssistantConnection").then((module) => ({ default: module.AssistantConnection }))); +const JoinRequestQueue = lazy(() => import("./pages/JoinRequestQueue").then((module) => ({ default: module.JoinRequestQueue }))); + +const CompanyExport = lazy(() => import("./pages/CompanyExport").then((module) => ({ default: module.CompanyExport }))); + +const ProductionAgents = lazy(() => import("./pages/Agents.production").then((module) => ({ default: module.Agents }))); +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 ProductionOrgChart = lazy(() => import("./pages/OrgChart.production").then((module) => ({ default: module.OrgChart }))); function ProductionSurface({ children }: { children: ReactNode }) { return }>{children}; @@ -762,12 +755,18 @@ function NoCompaniesStartPage() { } export function App() { + const { pathname } = useLocation(); const { enabled: streamlinedUiEnabled, loaded: streamlinedUiLoaded } = useStreamlinedUiEnabled(); const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled(); + useEffect(() => { + if (/^\/[^/]+\/(issues|tasks|inbox)(?:\/|$)/.test(pathname)) { + void loadIssueDetail().catch(() => undefined); + } + }, [pathname]); return ( <> - + }> } /> } /> } /> @@ -854,7 +853,7 @@ export function App() { } /> - + ); diff --git a/ui/src/components/BoardDialogs.test.tsx b/ui/src/components/BoardDialogs.test.tsx new file mode 100644 index 00000000000..08575ea269b --- /dev/null +++ b/ui/src/components/BoardDialogs.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { BoardDialogs } from "./BoardDialogs"; + +const state = vi.hoisted(() => ({ newIssueOpen: false, newProjectOpen: false, newGoalOpen: false, newAgentOpen: false })); +vi.mock("../context/DialogContext", () => ({ useDialogState: () => state, useDialogActions: () => ({ closeNewIssue: vi.fn(), closeNewProject: vi.fn(), closeNewGoal: vi.fn(), closeNewAgent: vi.fn() }) })); +vi.mock("./NewIssueDialog", () => ({ NewIssueDialog: () => })); +vi.mock("./NewProjectDialog", () => ({ NewProjectDialog: () =>
Project dialog
})); +vi.mock("./NewGoalDialog", () => ({ NewGoalDialog: () =>
Goal dialog
})); +vi.mock("./NewAgentDialog", () => ({ NewAgentDialog: () =>
Agent dialog
})); +let container: HTMLDivElement; +let root: ReturnType; +beforeEach(() => { + Object.assign(state, { newIssueOpen: false, newProjectOpen: false, newGoalOpen: false, newAgentOpen: false }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); +async function render() { await act(async () => { root.render(); }); } +it("leaves unopened dialog trees unmounted", async () => { + await render(); + expect(container.innerHTML).toBe(""); +}); +it("loads a requested dialog and retains its draft across close and reopen", async () => { + await render(); + state.newIssueOpen = true; + await render(); + const draft = container.querySelector("input")!; + expect(draft).not.toBeNull(); + draft.value = "Keep my draft"; + expect(container.textContent).not.toContain("Project dialog"); + state.newIssueOpen = false; + await render(); + state.newIssueOpen = true; + await render(); + expect(container.querySelector("input")).toBe(draft); + expect(container.querySelector("input")?.value).toBe("Keep my draft"); +}); diff --git a/ui/src/components/BoardDialogs.tsx b/ui/src/components/BoardDialogs.tsx new file mode 100644 index 00000000000..c4c17d798f7 --- /dev/null +++ b/ui/src/components/BoardDialogs.tsx @@ -0,0 +1,19 @@ +import { lazy } from "react"; +import { useDialogActions, useDialogState } from "../context/DialogContext"; +import { DeferredOverlay } from "./DeferredOverlay"; + +const NewIssueDialog = lazy(() => import("./NewIssueDialog").then((module) => ({ default: module.NewIssueDialog }))); +const NewProjectDialog = lazy(() => import("./NewProjectDialog").then((module) => ({ default: module.NewProjectDialog }))); +const NewGoalDialog = lazy(() => import("./NewGoalDialog").then((module) => ({ default: module.NewGoalDialog }))); +const NewAgentDialog = lazy(() => import("./NewAgentDialog").then((module) => ({ default: module.NewAgentDialog }))); + +export function BoardDialogs() { + const { newIssueOpen, newProjectOpen, newGoalOpen, newAgentOpen } = useDialogState(); + const { closeNewIssue, closeNewProject, closeNewGoal, closeNewAgent } = useDialogActions(); + return <> + + + + + ; +} diff --git a/ui/src/components/DeferredOverlay.test.tsx b/ui/src/components/DeferredOverlay.test.tsx new file mode 100644 index 00000000000..eee19620a81 --- /dev/null +++ b/ui/src/components/DeferredOverlay.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { act, lazy, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { DeferredOverlay } from "./DeferredOverlay"; + +const capture = vi.hoisted(() => vi.fn()); +vi.mock("../lib/sentry", () => ({ captureBrowserException: capture })); +let container: HTMLDivElement; +let root: ReturnType; +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + capture.mockClear(); +}); +it("dismisses a failed chunk without replacing the board or its draft", async () => { + const Failed = lazy(async () => { throw new Error("Chunk download failed"); }); + function Board() { + const [requested, setRequested] = useState(true); + return <>