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