diff --git a/src/app/App.plugin-owners.test.tsx b/src/app/App.plugin-owners.test.tsx
new file mode 100644
index 0000000000..8415ce55b4
--- /dev/null
+++ b/src/app/App.plugin-owners.test.tsx
@@ -0,0 +1,147 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import type { Context } from "@deepseek-ai/cordis";
+import { App } from "./App";
+import { createServices, type AppServices } from "./services";
+
+// One plugin with a plain page and a launcher panel that always throws. The
+// page doesn't host the companion, so the shell renders the panel outside it.
+vi.mock("../bundled", () => ({
+ bundledPlugins: [
+ {
+ manifest: { id: "test.owned", name: "Owned Tools", apiVersion: 1 },
+ module: {
+ inject: ["pages", "panels"],
+ apply(ctx: Context) {
+ ctx.pages.register({
+ id: "home",
+ title: "Home",
+ route: { version: 1, validate: () => true },
+ component: () =>
Home content
,
+ });
+ ctx.panels.register({
+ id: "broken",
+ title: "Broken tools",
+ matches: (target) => target === "test:broken",
+ launcher: { icon: "/buzz-mark.svg", target: "test:broken" },
+ component: () => {
+ throw new Error("Fixture companion failure");
+ },
+ });
+ },
+ },
+ },
+ ],
+}));
+
+beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ vi.stubGlobal("matchMedia", (media: string) => ({
+ media,
+ matches: false,
+ addEventListener() {},
+ removeEventListener() {},
+ }));
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+});
+let services: AppServices | undefined;
+afterEach(async () => {
+ cleanup();
+ await services?.dispose();
+ services = undefined;
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ localStorage.clear();
+ window.history.replaceState(null, "", "/");
+});
+
+// The catalog lists this bundled fixture as an external plugin when asked to.
+function start(external: boolean) {
+ window.history.replaceState(
+ null,
+ "",
+ `/#buzz=${encodeURIComponent(
+ JSON.stringify({
+ version: 1,
+ kind: "page",
+ pluginId: "test.owned",
+ pageId: "home",
+ }),
+ )}`,
+ );
+ const current = createServices();
+ services = current;
+ if (external) {
+ const read = current.plugins.snapshot;
+ let seen: ReturnType | undefined;
+ let shown: ReturnType | undefined;
+ current.plugins.snapshot = () => {
+ const next = read();
+ if (next !== seen) {
+ seen = next;
+ const { configuration } = next;
+ shown =
+ configuration.status === "ready"
+ ? {
+ ...next,
+ configuration: {
+ ...configuration,
+ catalog: {
+ ...configuration.catalog,
+ plugins: configuration.catalog.plugins.map((plugin) =>
+ plugin.manifest.id === "test.owned"
+ ? { ...plugin, source: "external" as const }
+ : plugin,
+ ),
+ },
+ },
+ }
+ : next;
+ }
+ return shown ?? next;
+ };
+ }
+ render();
+ return current;
+}
+
+it("names an external plugin when its launcher companion fails", async () => {
+ start(true);
+ expect(await screen.findByText("Home content")).toBeVisible();
+ fireEvent.click(screen.getByRole("button", { name: "Broken tools" }));
+
+ expect(
+ await screen.findByRole("heading", { name: "This panel couldn’t open" }),
+ ).toBeVisible();
+ expect(
+ screen.getByText("Owned Tools had a problem showing it."),
+ ).toBeVisible();
+ fireEvent.click(screen.getByRole("button", { name: "Manage plugin" }));
+ expect(await screen.findByRole("dialog", { name: "Settings" })).toBeVisible();
+ const target = decodeURIComponent(window.location.hash);
+ expect(target).toContain('"section":"plugins"');
+ expect(target).toContain('"pluginId":"test.owned"');
+});
+
+it("keeps a built-in companion's failure generic", async () => {
+ start(false);
+ expect(await screen.findByText("Home content")).toBeVisible();
+ fireEvent.click(screen.getByRole("button", { name: "Broken tools" }));
+
+ expect(
+ await screen.findByRole("heading", { name: "This panel couldn’t open" }),
+ ).toBeVisible();
+ expect(
+ screen.getByText("Something went wrong showing this panel."),
+ ).toBeVisible();
+ expect(screen.queryByRole("button", { name: "Manage plugin" })).toBeNull();
+});
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 20476e27fc..4b75782bd2 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -45,6 +45,8 @@ import { SettingsSidebar } from "./SettingsSidebar";
import { pendingManaged } from "../plugins/managed";
import { RecoveryScreen } from "./RecoveryScreen";
import { PageView } from "../features/pages/PageView";
+import { PluginOwnersProvider } from "../features/pages/ContributionFailure";
+import { pluginOwners } from "../features/pages/plugin-owners";
import { OwnedContribution } from "../plugins/OwnedContribution";
import { SidebarFrame } from "../features/channel-navigation/SidebarFrame";
import { SidebarList } from "../features/channel-navigation/SidebarList";
@@ -62,6 +64,7 @@ import { PanelLaunchers } from "./shell/PanelLaunchers";
import { ShellBackdrops } from "../features/backdrops/ShellBackdrops";
import { PanelCard } from "../features/panels/PanelCard";
import { communityDestination } from "../features/communities/destination";
+import { UNUSABLE_CONNECTION } from "../features/relay/ConnectionFailed";
import { CommunityLanding } from "../features/communities/CommunityLanding";
import { LegacyCommunityImport } from "../features/communities/LegacyCommunityImport";
import {
@@ -405,6 +408,9 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
: { scope: null }),
});
};
+ const owners = pluginOwners(configuration, (pluginId) =>
+ openSettingsSection("plugins", pluginId),
+ );
// As for pages, a timed-out reopen of the shown section keeps its editor.
const settingsUnavailable =
(!route.state.ingress && !!route.failure) || failedPage;
@@ -432,6 +438,9 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
route.reconnecting &&
!route.state.ingress &&
(failureReason === "timeout" || failureReason === "unavailable");
+ // An answer this Buzz can't use comes back the same on Try again.
+ const unusableConnection =
+ connection.status === "error" && connection.error?.kind === "unusable";
const retryButton = (variant: "primary" | "quiet") => (
}
title="Can’t connect"
description={
- retriedEntry === route.state.entry.id
- ? keepTrying
- ? "Still can’t connect. We’ll keep trying."
- : "Still can’t connect. Check your connection, then try again."
- : keepTrying
- ? "Check your connection. We’ll keep trying."
- : "Check your connection, then try again."
+ unusableConnection
+ ? UNUSABLE_CONNECTION
+ : retriedEntry === route.state.entry.id
+ ? keepTrying
+ ? "Still can’t connect. We’ll keep trying."
+ : "Still can’t connect. Check your connection, then try again."
+ : keepTrying
+ ? "Check your connection. We’ll keep trying."
+ : "Check your connection, then try again."
}
action={
<>
- {retryButton("primary")}
{messagesButton("quiet")}
+ {!unusableConnection && retryButton("primary")}
>
}
/>
@@ -537,6 +548,7 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
description="Turn it on in Manage plugins to open this page."
action={
<>
+ {messagesButton("quiet")}
- {messagesButton("quiet")}
>
}
/>
@@ -557,8 +568,8 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
description="It may have been removed, or you may need to update Buzz."
action={
<>
- {messagesButton("primary")}
{retryButton(leaveForMessages ? "quiet" : "primary")}
+ {messagesButton("primary")}
>
}
/>
@@ -569,8 +580,8 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
description="It didn’t load this time. Try again in a moment."
action={
<>
- {retryButton("primary")}
{messagesButton("quiet")}
+ {retryButton("primary")}
>
}
/>
@@ -601,138 +612,144 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
/>
)}
- (
-
- )}
- workspaceRef={setWorkspaceElement}
- sidebar={(pageNavigation, creationAction) =>
- workspace.page?.sidebar && pageSidebarReady ? (
-
-
-
- This page’s sidebar is unavailable.
- {pageNavigation}
-
-
-
- }
- >
- {(entry) => {
- const Sidebar = entry.sidebar;
- return Sidebar ? (
-
- {pageNavigation}
-
- ) : null;
- }}
-
- ) : workspace.page?.sidebar ? (
-
-
- {pageNavigation}
-
-
- ) : (
-
+ (
+
- {pageNavigation}
-
- )
- }
- navigationControls={
-
- }
- onCommunitySelect={selectCommunity}
- // A scoped Settings target selects its community on the way.
- onOpenTarget={(target) => void services.navigation.open(target)}
- communities={services.communities}
- federatedIdentity={services.federatedIdentity}
- invite={invite?.viewer === client.viewer ? invite : undefined}
- onInviteClose={services.invites.clear}
- settingsCards={services.settingsCards}
- accountActions={services.accountActions}
- appearance={services.appearance}
- pluginRecommendations={pluginRecommendations}
- searchServices={services}
- launchers={
-
- }
- backdrop={}
- companion={pageOwnsCompanion ? undefined : companion}
- pages={startup === "ready" ? route.pages : []}
- selected={workspace.selected}
- navigationAttempt={workspace.attempt}
- onSelect={select}
- tone={presentation.tone}
- workspace={
- startup === "ready" && workspace.page?.layout === "workspace"
- }
- >
-
-
-
+ )}
+ workspaceRef={setWorkspaceElement}
+ sidebar={(pageNavigation, creationAction) =>
+ workspace.page?.sidebar && pageSidebarReady ? (
+
+
+
+
+ This page’s sidebar is unavailable.
+
+ {pageNavigation}
+
+
+
+ }
+ >
+ {(entry) => {
+ const Sidebar = entry.sidebar;
+ return Sidebar ? (
+
+ {pageNavigation}
+
+ ) : null;
+ }}
+
+ ) : workspace.page?.sidebar ? (
+
+
+ {pageNavigation}
+
+
+ ) : (
+
+ {pageNavigation}
+
+ )
+ }
+ navigationControls={
+
+ }
+ onCommunitySelect={selectCommunity}
+ // A scoped Settings target selects its community on the way.
+ onOpenTarget={(target) => void services.navigation.open(target)}
communities={services.communities}
- relay={services.relay}
- />
-
-
-
-
-
- {!settings && startup === "recovery" ? (
-
- ) : !settings &&
- ((!route.state.ingress && route.failure) || failedPage) ? (
- destinationFailure
- ) : !settings && route.waiting ? (
- opening
- ) : startup === "loading" || restoring ? (
- settings ? null : (
- opening
- )
- ) : workspace.page ? (
-
+ }
+ backdrop={}
+ companion={pageOwnsCompanion ? undefined : companion}
+ pages={startup === "ready" ? route.pages : []}
+ selected={workspace.selected}
+ navigationAttempt={workspace.attempt}
+ onSelect={select}
+ tone={presentation.tone}
+ workspace={
+ startup === "ready" && workspace.page?.layout === "workspace"
+ }
+ >
+
+
+
- ) : null}
-
+
+
+
+
+
+ {!settings && startup === "recovery" ? (
+
+ ) : !settings &&
+ ((!route.state.ingress && route.failure) || failedPage) ? (
+ destinationFailure
+ ) : !settings && route.waiting ? (
+ opening
+ ) : startup === "loading" || restoring ? (
+ settings ? null : (
+ opening
+ )
+ ) : workspace.page ? (
+
+ ) : null}
+
+