diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 2efa09d6c0..88560f1ae8 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -600,4 +600,18 @@ for (const key of ["home.firstGoal.title", "home.firstGoal.lead", "home.firstGoa assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); } +// Load boundaries: settings are a separate chunk that arrives on demand and +// the secondary routes are not part of the workspace's first load. Each lazy +// site keeps a visible loading state. Team results stay eager: their list +// must load exactly once when a configured team appears, and a lazy mount +// shifted that timing (see the loopx-mode scenario). +const router = source("../../router.tsx"); +assert.match(page, /const WorkspaceSettingsPage = lazy\(\(\) => import\("\.\/workspace-settings-page"\)/, "Settings load on demand"); +assert.doesNotMatch(page, /^import \{ WorkspaceSettingsPage \}/m, "No eager import undoes the boundary"); +assert.match(page, /^import \{ GoalTeamResults \} from "\.\/goal-team-results";/m, "Team results load eagerly so their single list request keeps its timing"); +for (const route of ["./views/frontstage-developer-page", "./views/benchmark-study-page", "./features/personal-workspace/answer-report-page"]) { + assert.match(router, new RegExp(`lazy\\(\\(\\) => import\\("${route.replace(/[./]/g, (ch) => `\\${ch}`)}"\\)`), `${route} loads on demand`); +} +assert.equal((page.match(//g) ?? []).length, 1, "The lazy settings site shows a loading status"); + console.log("personal workspace drawer contract smoke passed"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index d982251735..2fba78b8dd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -14,7 +14,7 @@ import { } from "../../../../../../loopx/control_plane/presentation/action_review_plan.js"; import { refreshAttention } from "./attention-details"; import { teamPlanAssignments, teamPlanAppliedLine, teamPlanAppliedOutcome, teamPlanFields, teamPlanGoalId, teamPlanLaneCount, teamPlanReceiptGapLanes, teamPlanTodoIds } from "./team-plan-preview"; -import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type ReactNode } from "react"; +import { Suspense, lazy, useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type ReactNode } from "react"; import { AlertCircle, Bot, CalendarClock, FileText, ListPlus, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, X } from "lucide-react"; import { @@ -84,7 +84,9 @@ import { goalWorkKind } from "./goal-activity"; import { WorkspaceActionForm, type WorkspaceActionDraft } from "./workspace-action-form"; import { GoalActivityChip, GoalIdentityMark } from "./goal-activity-view"; import { ManagerBrief } from "./manager-brief"; -import { WorkspaceSettingsPage } from "./workspace-settings-page"; +// Loaded on demand: settings and their editors are a separate chunk that +// arrives when the owner first opens Settings, not on every workspace visit. +const WorkspaceSettingsPage = lazy(() => import("./workspace-settings-page").then((module) => ({ default: module.WorkspaceSettingsPage }))); import { UsageStatisticsNotice } from "./usage-statistics-notice"; import { readWorkspaceTheme, writeWorkspaceTheme, type WorkspaceTheme } from "./workspace-theme"; import { compareProposalRecency } from "./proposal-recency"; @@ -2091,6 +2093,7 @@ export function PersonalWorkspacePage({ } const settingsPage = settingsOpen ? ( + {t("common.loading")}

}> +
) : null; const composerSuggestions = !selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
{ diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index d4172394a9..5b6d45a5d4 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -2073,3 +2073,5 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-conversation-latest { display: block; margin: 0 auto 10px; padding: 7px 14px; border: 1px solid var(--pw-line); border-radius: 20px; background: var(--pw-bg); color: var(--pw-text); font: inherit; font-size: var(--text-caption); cursor: pointer; } .personal-composer-hint { padding-top: 7px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); text-align: center; } + +.personal-lazy-loading { margin: 0; padding: 24px; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); } diff --git a/apps/presentation/dashboard/src/router.tsx b/apps/presentation/dashboard/src/router.tsx index 1b1cb9282c..6afc09644f 100644 --- a/apps/presentation/dashboard/src/router.tsx +++ b/apps/presentation/dashboard/src/router.tsx @@ -8,11 +8,19 @@ import { import { z } from "zod"; import { DashboardPage } from "./views/dashboard-page"; -import { FrontstageDeveloperPage } from "./views/frontstage-developer-page"; -import { useEffect } from "react"; +import { Suspense, lazy, useEffect } from "react"; import { resolveLocalStatusUrl } from "./data/local-status-query"; -import { BenchmarkStudyPage } from "./views/benchmark-study-page"; -import { AnswerReportPage } from "./features/personal-workspace/answer-report-page"; + +// Secondary routes load on demand. The workspace is what every visit opens; +// contributor projections, benchmark studies and answer reports arrive as +// their own chunks only when such a route is entered. +const FrontstageDeveloperPage = lazy(() => import("./views/frontstage-developer-page").then((module) => ({ default: module.FrontstageDeveloperPage }))); +const BenchmarkStudyPage = lazy(() => import("./views/benchmark-study-page").then((module) => ({ default: module.BenchmarkStudyPage }))); +const AnswerReportPage = lazy(() => import("./features/personal-workspace/answer-report-page").then((module) => ({ default: module.AnswerReportPage }))); + +function RouteLoading() { + return
加载中… / Loading…
; +} const searchSchema = z.object({ goalId: z.string().optional().default(""), @@ -84,7 +92,7 @@ export const dashboardRoute = createRoute({ component: () => { const search = dashboardRoute.useSearch(); return search.reportSessionId && search.reportMessageId - ? + ? }> : ; }, }); @@ -112,14 +120,14 @@ export const frontstageDeveloperRoute = createRoute({ export const projectionDeveloperRoute = createRoute({ getParentRoute: () => rootRoute, path: "/developers/projections", - component: FrontstageDeveloperPage, + component: () => }>, }); export const benchmarkStudyRoute = createRoute({ getParentRoute: () => rootRoute, path: "/benchmarks/study", validateSearch: (search) => benchmarkStudySearchSchema.parse(search), - component: BenchmarkStudyPage, + component: () => }>, }); const routeTree = rootRoute.addChildren([ diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index 8d4792db8e..e5ce7329f4 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -148,6 +148,18 @@ can require a rebuild. Text inputs normalize CRLF so a CI-built bundle remains valid on Windows; delivered bytes are always hashed exactly. This is integrity and freshness metadata, not a signature or a replacement for release attestations. +### Load boundaries + +The workspace route is what every visit opens, so it loads eagerly. Settings +and its editors, the contributor projection page, the benchmark study page and +the answer report page are dynamic imports: each is its own chunk fetched the +first time it is shown, behind a visible loading status. Keep new secondary +surfaces behind the same kind of boundary rather than adding them to the entry +chunk, and keep a surface eager when a scenario pins the timing of its first +read (team results must list exactly once when a configured team appears). The bundle manifest records every chunk, +and the one-upgrade asset retention already covers an old tab importing a +deferred chunk after a delivery. + ### Goal sidebar order Goal dragging and keyboard move controls save presentation order through