diff --git a/src/main.tsx b/src/main.tsx
index 2ebeab8b1..eaef68621 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -24,7 +24,15 @@ import { useTranslation } from "react-i18next";
import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext";
import { ConnectionDefaultsProvider } from "@/contexts/ConnectionDefaultsContext";
import { BrandingProvider } from "@/contexts/BrandingContext";
-import { fetchGuestViews, startPluginRuntime } from "@/plugin-host/loader";
+import {
+ fetchGuestViews,
+ startPluginRuntime,
+ stopPluginRuntime,
+} from "@/plugin-host/loader";
+import { settledPromise } from "@/plugin-host/plugin-store";
+import { resetShellBridge } from "@/plugin-host/shell-bridge";
+import { preloadPermissions } from "@/hooks/use-permissions";
+import { getUserPreferences } from "@/api/open-tabs-api";
import { standaloneViewFor } from "@/shell/tab-registry";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
@@ -39,7 +47,12 @@ const ElectronVersionCheck = lazy(() =>
);
type Phase =
- "verifying" | "idle-auth" | "fading-in" | "idle-app" | "fading-out";
+ | "verifying"
+ | "idle-auth"
+ | "loading-app"
+ | "fading-in"
+ | "idle-app"
+ | "fading-out";
type LogoutOptions = {
manual?: boolean;
@@ -180,6 +193,14 @@ function App() {
}
}
fadingInFromLoginRef.current = false;
+ setPhase("loading-app");
+ await Promise.all([
+ startPluginRuntime()
+ .catch(() => {})
+ .then(() => settledPromise()),
+ preloadPermissions(),
+ getUserPreferences().catch(() => undefined),
+ ]);
setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
})
@@ -238,8 +259,18 @@ function App() {
clearDesktopManualLogout();
setAuthUsername(u);
fadingInFromLoginRef.current = true;
- setPhase("fading-in");
- timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
+ setPhase("loading-app");
+ void (async () => {
+ await Promise.all([
+ startPluginRuntime()
+ .catch(() => {})
+ .then(() => settledPromise()),
+ preloadPermissions(),
+ getUserPreferences().catch(() => undefined),
+ ]);
+ setPhase("fading-in");
+ timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
+ })();
if (isElectron()) {
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
const localJwt = localStorage.getItem("jwt");
@@ -264,6 +295,10 @@ function App() {
// isn't subject to batching, so it correctly dedupes within one tick.
if (loggingOutRef.current) return;
loggingOutRef.current = true;
+ // The next user may see different plugins, so nothing they registered
+ // should survive into the next session.
+ void stopPluginRuntime();
+ resetShellBridge();
clearStoredAuth();
localStorage.removeItem("jwt");
if (isElectron() && options?.manual) {
@@ -297,7 +332,7 @@ function App() {
const { t } = useTranslation();
const isTransitioning = phase === "fading-in" || phase === "fading-out";
- if (phase === "verifying") {
+ if (phase === "verifying" || phase === "loading-app") {
return (
diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx
index b7dd36e8e..66731f9a5 100644
--- a/src/ui/AppShell.tsx
+++ b/src/ui/AppShell.tsx
@@ -175,12 +175,10 @@ import {
} from "@/shell/tab-registry";
import { getPanel, usePanels } from "@/shell/panel-registry";
import { invokeAction } from "@/shell/action-registry";
-import { startPluginRuntime, stopPluginRuntime } from "@/plugin-host/loader";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
notifyShellReady,
notifyTabsChanged,
- resetShellBridge,
setShellCallbacks,
setShellHosts,
setShellLayoutProvider,
@@ -401,17 +399,6 @@ export function AppShell({
.catch(() => setIsAdmin(false));
}, []);
- // Plugin frontends register the tabs, panels and actions that a saved
- // session may need, so they load before tabs are restored. On logout every
- // plugin is deactivated, since the next user may see different ones.
- useEffect(() => {
- startPluginRuntime().catch(() => {});
- return () => {
- void stopPluginRuntime();
- resetShellBridge();
- };
- }, []);
-
const handleDismissDonationModal = useCallback(() => {
setShowDonationModal(false);
dismissDonationModal().catch(() => {});
@@ -2661,6 +2648,7 @@ export function AppShell({
splitMode={splitMode}
username={username}
isAdmin={showAdminUI}
+ pluginsSettled={pluginsSettled}
onRailClick={handleRailClick}
onOpenTab={openSingletonTab}
onOpenInRightDock={openInRightDock}
diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts
index 565a6ca97..78c92c049 100644
--- a/src/ui/api/open-tabs-api.ts
+++ b/src/ui/api/open-tabs-api.ts
@@ -169,9 +169,19 @@ export function parseCustomKeybindings(
}
}
+const userPreferencesCache = createTtlRequestCache
(10_000);
+
+/**
+ * Cached briefly so the loading gate's prefetch and the shell's own read on
+ * mount share one request instead of two, which is what let stale
+ * localStorage flags (hiddenRailTabs, pinAppRail) flash before the real
+ * values landed a moment after the shell first rendered.
+ */
export async function getUserPreferences(): Promise {
- const response = await authApi.get("/user-preferences");
- return response.data;
+ return userPreferencesCache.get(async () => {
+ const response = await authApi.get("/user-preferences");
+ return response.data;
+ });
}
export async function saveUserPreferences(
diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx
index c9ec91c3e..e1133897b 100644
--- a/src/ui/dashboard/DashboardTab.tsx
+++ b/src/ui/dashboard/DashboardTab.tsx
@@ -3,9 +3,11 @@ import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
import { ComponentSlot } from "@/shell/ActionSlot";
import { useActionSlot } from "@/hooks/use-action-slot";
import { useIsMobile } from "@/hooks/use-mobile";
+import { usePluginStore } from "@/plugin-host/plugin-store";
import { Button } from "@/components/button";
import { Card } from "@/components/card";
import { Separator } from "@/components/separator";
+import { Skeleton } from "@/components/skeleton";
import {
Activity,
Database,
@@ -1049,6 +1051,7 @@ export function DashboardTab({
// canvas preview. Only one is offered today; "dashboard" always exists.
const secondaryViews = useActionSlot("dashboard.secondaryView");
const secondaryView = secondaryViews[0];
+ const { settled: pluginsSettled } = usePluginStore();
const [dashboardView, setDashboardView] = useState(() => {
try {
@@ -1057,11 +1060,14 @@ export function DashboardTab({
return "dashboard";
}
});
+ // Until plugins settle, a plugin view saved from last session hasn't had a
+ // chance to register yet, so its absence doesn't mean it's really gone.
+ const viewPending = !pluginsSettled && dashboardView !== "dashboard";
// Falls back to the dashboard when the view a plugin contributed is off.
const isDashboardView =
dashboardView === "dashboard" ||
- !secondaryView ||
- dashboardView !== secondaryView.actionId;
+ (!viewPending && !secondaryView) ||
+ (!!secondaryView && dashboardView !== secondaryView.actionId);
useEffect(() => {
try {
@@ -1482,23 +1488,27 @@ export function DashboardTab({
-
-
- {secondaryView && (
+ {viewPending ? (
+
+ ) : (
+
- )}
-
- {isDashboardView && (
+ {secondaryView && (
+
+ )}
+
+ )}
+ {isDashboardView && !viewPending && (
{todayLabel}
@@ -1621,7 +1631,13 @@ export function DashboardTab({
- {!isDashboardView && secondaryView?.component ? (
+ {viewPending ? (
+
+
+
+
+
+ ) : !isDashboardView && secondaryView?.component ? (
diff --git a/src/ui/hooks/use-permissions.ts b/src/ui/hooks/use-permissions.ts
index 07a4c9694..f8df68e19 100644
--- a/src/ui/hooks/use-permissions.ts
+++ b/src/ui/hooks/use-permissions.ts
@@ -56,6 +56,15 @@ export async function hasPermission(permission: string): Promise
{
return matchesPermission(permissions, isAdmin, permission);
}
+/**
+ * Warms the permissions cache before the shell mounts, so permission-gated
+ * rail items are already known on first paint instead of popping in once
+ * usePermissions' own fetch resolves.
+ */
+export function preloadPermissions(): Promise {
+ return load().then(() => undefined);
+}
+
export function notifyPermissionsChanged(): void {
cache = null;
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
diff --git a/src/ui/plugin-host/loader.ts b/src/ui/plugin-host/loader.ts
index ebd49da4a..628f011ee 100644
--- a/src/ui/plugin-host/loader.ts
+++ b/src/ui/plugin-host/loader.ts
@@ -300,22 +300,53 @@ async function reconcile(summaries: PluginSummary[]): Promise {
if (!wanted.has(id)) failedVersions.delete(id);
}
- for (const summary of order) {
- if (active.has(summary.id)) continue;
- // Do not retry a bundle that already failed until it changes.
- if (
- failedVersions.has(summary.id) &&
- failedVersions.get(summary.id) === (summary.assetVersion ?? null)
- ) {
- setFrontendState(
- summary.id,
- "failed",
- getPluginRecord(summary.id)?.error,
+ await activateConcurrently(order);
+}
+
+/**
+ * Activates every plugin in `order`, running independent plugins in
+ * parallel instead of one at a time. Each plugin still waits for its own
+ * hard dependencies to finish activating first (success or failure - a
+ * blocked or failed dependency must not hang its dependents forever).
+ */
+async function activateConcurrently(order: PluginSummary[]): Promise {
+ const byId = new Map(order.map((summary) => [summary.id, summary]));
+ const started = new Map>();
+
+ const run = (summary: PluginSummary): Promise => {
+ const existing = started.get(summary.id);
+ if (existing) return existing;
+
+ const task = (async () => {
+ const dependencyIds = Object.keys(summary.dependencies ?? {});
+ await Promise.all(
+ dependencyIds.map((id) => {
+ const dependency = byId.get(id);
+ return dependency ? run(dependency) : Promise.resolve();
+ }),
);
- continue;
- }
- await activate(summary);
- }
+
+ if (active.has(summary.id)) return;
+ // Do not retry a bundle that already failed until it changes.
+ if (
+ failedVersions.has(summary.id) &&
+ failedVersions.get(summary.id) === (summary.assetVersion ?? null)
+ ) {
+ setFrontendState(
+ summary.id,
+ "failed",
+ getPluginRecord(summary.id)?.error,
+ );
+ return;
+ }
+ await activate(summary);
+ })();
+
+ started.set(summary.id, task);
+ return task;
+ };
+
+ await Promise.all(order.map(run));
}
function enqueue(task: () => Promise): Promise {
diff --git a/src/ui/sidebar/AppRail.tsx b/src/ui/sidebar/AppRail.tsx
index edd3d2d64..1d095f79e 100644
--- a/src/ui/sidebar/AppRail.tsx
+++ b/src/ui/sidebar/AppRail.tsx
@@ -11,6 +11,7 @@ import {
User,
} from "lucide-react";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
+import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useRailItems, type RailItemDef } from "./rail-items";
@@ -99,6 +100,7 @@ export function AppRail({
splitMode,
username,
isAdmin,
+ pluginsSettled = true,
onRailClick,
onOpenTab,
onOpenInRightDock,
@@ -109,6 +111,8 @@ export function AppRail({
splitMode: SplitMode;
username: string;
isAdmin: boolean;
+ /** False while plugins are still registering their rail items. */
+ pluginsSettled?: boolean;
onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onOpenInRightDock?: (view: RailView) => void;
@@ -236,92 +240,102 @@ export function AppRail({
}}
>
- {railButtons.map((item, i) =>
- item.kind === "separator" ? (
-
- ) : "tabType" in item ? (
-
diff --git a/src/ui/tests/plugin-host/loader.test.tsx b/src/ui/tests/plugin-host/loader.test.tsx
index 879055e4f..1b193aebf 100644
--- a/src/ui/tests/plugin-host/loader.test.tsx
+++ b/src/ui/tests/plugin-host/loader.test.tsx
@@ -260,4 +260,43 @@ describe("activation order", () => {
expect(order).toEqual([]);
expect([...blocked.keys()].sort()).toEqual(["a", "b"]);
});
+
+ it("activates independent plugins concurrently rather than one at a time", async () => {
+ modules.alpha = railPlugin("alpha");
+ modules.beta = railPlugin("beta");
+ plugins = [summary("alpha"), summary("beta")];
+
+ // Each import blocks until the other has started, which only resolves
+ // if both imports were kicked off before either finished.
+ let releaseAlpha: () => void;
+ let releaseBeta: () => void;
+ const alphaStarted = new Promise((resolve) => {
+ releaseAlpha = resolve;
+ });
+ const betaStarted = new Promise((resolve) => {
+ releaseBeta = resolve;
+ });
+ configurePluginLoader({
+ fetchPlugins: async () => plugins,
+ importFrontend: async (entry) => {
+ if (entry.id === "alpha") {
+ releaseAlpha!();
+ await betaStarted;
+ } else if (entry.id === "beta") {
+ releaseBeta!();
+ await alphaStarted;
+ }
+ const module = modules[entry.id];
+ if (!module) throw new Error(`no module for ${entry.id}`);
+ return module;
+ },
+ loadLocale: async () => null,
+ injectCss: () => null,
+ });
+
+ await syncPlugins();
+
+ expect(railIds()).toContain("alpha-panel");
+ expect(railIds()).toContain("beta-panel");
+ });
});