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") => ( - {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} + + ping() }; } -function offlineMessagesFixture() { +/** Messages opened while the session read answers `status`, until `online`. */ +function offlineMessagesFixture(status = 503) { vi.stubGlobal( "ResizeObserver", class { @@ -527,7 +528,7 @@ function offlineMessagesFixture() { if (url.endsWith("/identity")) return Response.json({ viewer }); if (url.endsWith("/session")) return offline - ? Response.json({ error: "offline" }, { status: 503 }) + ? Response.json({ error: "offline" }, { status }) : Response.json({ viewer, relayAuthor: viewer, relayUrl: origin }); if (url.endsWith("/query")) return Response.json([]); return Response.json({}); @@ -603,6 +604,42 @@ it("reopens a visit that timed out while connecting once the connection is live" vi.useRealTimers(); } }); +it("says only that Buzz couldn't connect, with no Try again, when the session answer can't be used", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + // An edge answering 400 during a relay deploy. + const { current, online } = offlineMessagesFixture(400); + await waitFor(() => + expect(current.relay.snapshot()).toMatchObject({ + status: "error", + error: { kind: "unusable" }, + }), + ); + await act(() => vi.advanceTimersByTimeAsync(15_000)); + const heading = await screen.findByRole("heading", { + name: "Can’t connect", + }); + const card = heading.closest('[role="alert"]'); + assert.exists(card); + expect( + within(card).getByText("Buzz couldn’t connect to this community."), + ).toBeVisible(); + // Trying again gets the same answer, so the card doesn't ask for it. + expect( + within(card).queryByRole("button", { name: "Try again" }), + ).toBeNull(); + expect(card).not.toHaveTextContent(/try again|check your connection/i); + // The relay still asks again by itself, and the visit reopens. + online(); + await act(() => vi.advanceTimersByTimeAsync(31_000)); + await waitFor(() => + expect(current.navigation.snapshot().status).toBe("opened"), + ); + expect(screen.queryByText(pageFailure)).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); it("does not reopen a failed visit after the user has left it", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); try { @@ -783,11 +820,11 @@ it("keeps a failed message read in place until its own Retry succeeds", async () document.querySelector('[data-channel-timeline="alpha"]'); while (!timeline()) { await userEvent.click( - await screen.findByRole("button", { name: "Retry messages" }), + await screen.findByRole("button", { name: "Try again" }), ); await waitFor(() => expect( - timeline() ?? screen.queryByRole("button", { name: "Retry messages" }), + timeline() ?? screen.queryByRole("button", { name: "Try again" }), ).not.toBeNull(), ); } @@ -798,7 +835,7 @@ it("keeps a failed message read in place until its own Retry succeeds", async () .session.channels.window("alpha") .rows.map((row) => row.content), ).toEqual(["Back again"]); - expect(screen.queryByRole("button", { name: "Retry messages" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); expect(current.navigation.snapshot().attempt.signal.aborted).toBe(false); expect(failures).toBe(0); expect(current.relay.snapshot().generation).toBe(generation); @@ -1258,7 +1295,7 @@ async function activityTurnedOff() { within(card) .getAllByRole("button") .map((button) => button.textContent), - ).toEqual(["Manage plugins", "Go to Messages"]); + ).toEqual(["Go to Messages", "Manage plugins"]); return card; } @@ -1352,7 +1389,7 @@ it("turning a plugin back on over its failed page offers Try again, which opens within(card) .getAllByRole("button") .map((button) => button.textContent), - ).toEqual(["Try again", "Go to Messages"]); + ).toEqual(["Go to Messages", "Try again"]); expect( screen.queryByRole("heading", { name: "Activity is turned off" }), ).not.toBeInTheDocument(); diff --git a/src/app/services.test.ts b/src/app/services.test.ts index 41c68159da..9312564328 100644 --- a/src/app/services.test.ts +++ b/src/app/services.test.ts @@ -441,7 +441,7 @@ it("restores the selected native community and recovers discovery failure withou await vi.advanceTimersByTimeAsync(0); expect(services.relay.snapshot()).toMatchObject({ status: "error", - error: expect.stringContaining("Discovery offline"), + error: { details: expect.stringContaining("Discovery offline") }, }); unavailable = false; services.relay.retry(); @@ -503,7 +503,7 @@ it("seeds a packaged native community after onboarding and preserves the user's }); expect(services.relay.snapshot()).toMatchObject({ status: "error", - error: expect.stringContaining("Discovery offline"), + error: { details: expect.stringContaining("Discovery offline") }, }); unavailable = false; services.relay.retry(); diff --git a/src/app/shell/PageSearch.test.tsx b/src/app/shell/PageSearch.test.tsx index 892cc8a1af..e7a784d311 100644 --- a/src/app/shell/PageSearch.test.tsx +++ b/src/app/shell/PageSearch.test.tsx @@ -838,7 +838,7 @@ it("finds and opens saved conversations while the community is unreachable or st ); let connection: RelaySnapshot = { status: "error", - error: "Relay broker session is malformed", + error: { kind: "connection", details: "Relay broker session is malformed" }, cached: true, generation: 1, scope: "s", diff --git a/src/app/shell/PrimarySidebar.test.tsx b/src/app/shell/PrimarySidebar.test.tsx index b3a6cc8986..9b18c644d6 100644 --- a/src/app/shell/PrimarySidebar.test.tsx +++ b/src/app/shell/PrimarySidebar.test.tsx @@ -1022,7 +1022,10 @@ it("moves a refusal's recovery from the sidebar to its one notice", async () => ); const sidebar = () => screen.getByRole("complementary", { name: "Sidebar" }); // A connection failure with native's sign-in error asks for sign-in here. - f.connection({ status: "error", error: "Federated sign-in is required" }); + f.connection({ + status: "error", + error: { kind: "refused", details: "Federated sign-in is required" }, + }); expect( within(sidebar()).getByRole("button", { name: "Sign in with builderlab.example", @@ -1050,7 +1053,10 @@ it("shows Try again beside a settled failure, and Connecting while the connectio const f = fixture(true); const retry = vi.spyOn(f.relay, "retry"); const sidebar = () => screen.getByRole("complementary", { name: "Sidebar" }); - const error = "ReadError: Relay read failed (502)"; + const error = { + kind: "connection", + details: "ReadError: Relay read failed (502)", + } as const; f.connection({ status: "error", error }); expect(within(sidebar()).getByText("Couldn’t load channels.")).toBeVisible(); expect(within(sidebar()).queryByText(/ReadError/)).toBeNull(); @@ -1062,7 +1068,10 @@ it("shows Try again beside a settled failure, and Connecting while the connectio // every page; the sidebar neither repeats it nor offers Try again. f.connection({ status: "error", - error: "ReadError: Relay read failed (403)", + error: { + kind: "refused", + details: "ReadError: Relay read failed (403)", + }, denied: true, }); const notice = await screen.findByRole("dialog", { diff --git a/src/app/shell/PrimarySidebar.tsx b/src/app/shell/PrimarySidebar.tsx index 5c1f50ff3f..334edb5300 100644 --- a/src/app/shell/PrimarySidebar.tsx +++ b/src/app/shell/PrimarySidebar.tsx @@ -266,7 +266,7 @@ function ScopedSidebar({ sidebarRows={props.sidebarRows} cached={!!connection.cached} layoutReady={filter === "team" || soloReady} - connectionError={connection.error} + connectionError={connection.error?.details} render={({ sidebar, dmPreviews, @@ -411,7 +411,7 @@ function ScopedSidebar({ props.federatedIdentity, connection.origin, )} - error={connection.error} + error={connection.error?.details} retry={props.relay.retry} relay={connection.origin} memberships={props.memberships} diff --git a/src/bundled/bestie/PinnedBestie.test.tsx b/src/bundled/bestie/PinnedBestie.test.tsx index b7b102cfea..235eb43b0a 100644 --- a/src/bundled/bestie/PinnedBestie.test.tsx +++ b/src/bundled/bestie/PinnedBestie.test.tsx @@ -735,15 +735,62 @@ it("keeps the saved Bestie visible through reconnect and offers connection recov screen.queryByRole("button", { name: "Set up Bestie" }), ).not.toBeInTheDocument(); act(() => - f.updateRelay({ status: "error", error: "Community temporarily offline" }), + f.updateRelay({ + status: "error", + error: { + kind: "connection", + details: "ReadError: Relay read failed (502)", + }, + }), ); - expect(screen.getByText("Community temporarily offline")).toBeVisible(); + expect( + screen.getByText("Check your connection, then try again."), + ).toBeVisible(); + expect(screen.queryByText(/ReadError|502/)).not.toBeInTheDocument(); await userEvent.click( screen.getByRole("button", { name: "Retry connection" }), ); expect(f.retryConnection).toHaveBeenCalledOnce(); }); +it("offers Check for updates, not Retry connection, for an answer Buzz can't use", async () => { + const f = await fixture({ existing: true }); + f.choose(pollen); + const open = vi.fn(async () => ({ status: "opened" }) as const); + render( + f.element({ + renderedNavigator: Object.create(f.navigator, { open: { value: open } }), + }), + ); + await screen.findByRole("textbox", { name: "Message Pollen" }); + act(() => + f.updateRelay({ + status: "error", + error: { + kind: "unusable", + details: "ReadError: Relay response is not an event array", + }, + }), + ); + expect( + screen.getByText("Buzz couldn’t connect to this community."), + ).toBeVisible(); + expect(screen.queryByText(/try again/i)).not.toBeInTheDocument(); + // Reconnecting gets the same answer; an update may not. + expect( + screen.queryByRole("button", { name: "Retry connection" }), + ).not.toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: "Check for updates" }), + ); + expect(open).toHaveBeenCalledExactlyOnceWith({ + version: 1, + kind: "settings", + section: "updates", + }); + expect(f.retryConnection).not.toHaveBeenCalled(); +}); + it("recovers a saved agent missing from a partial inventory through refresh", async () => { const f = await fixture({ localFailed: true }); f.choose(pollen); diff --git a/src/bundled/bestie/PinnedBestie.tsx b/src/bundled/bestie/PinnedBestie.tsx index 51f00008d0..1c857bc897 100644 --- a/src/bundled/bestie/PinnedBestie.tsx +++ b/src/bundled/bestie/PinnedBestie.tsx @@ -10,6 +10,11 @@ import { useAgentChoices } from "../../features/agents/use-choices"; import { useChannelList, useRelayConnection } from "../../features/relay/react"; import type { ChannelSummary } from "../../features/relay/contracts"; import type { RelayData } from "../../features/relay/service"; +import { + connectionFailureAction, + connectionFailureText, + openUpdatesWith, +} from "../../features/relay/ConnectionFailed"; import type { RelaySession } from "../../features/relay/session"; import { useSavedAgent } from "../../features/solo/default-agent"; import { RobotIcon } from "../../shared/design-system/icons"; @@ -91,14 +96,15 @@ export function PinnedBestie({ description={ connection.status === "connecting" ? "Your bestie conversation opens once this community is connected." - : (connection.error ?? - "Choose a community to chat with your bestie.") - } - action={ - connection.status === "error" ? ( - - ) : undefined + : connection.error + ? connectionFailureText(connection) + : "Choose a community to chat with your bestie." } + action={connectionFailureAction({ + connection, + relay, + openUpdates: openUpdatesWith(navigator), + })} /> ); } else if (channel && saved) { diff --git a/src/bundled/bestie/index.test.tsx b/src/bundled/bestie/index.test.tsx index 33ed4f3027..894a910e09 100644 --- a/src/bundled/bestie/index.test.tsx +++ b/src/bundled/bestie/index.test.tsx @@ -41,6 +41,38 @@ const renderPage = (h: Awaited>) => { return render(); }; +it("offers Check for updates, not Retry connection, for an answer Buzz can't use", async () => { + const h = await bestieFixture(native.invoke); + act(() => + h.updateRelay({ + status: "error", + generation: 2, + error: { + kind: "unusable", + details: "ReadError: Relay response is not an event array", + }, + }), + ); + renderPage(h); + expect( + await screen.findByText("Buzz couldn’t connect to this community."), + ).toBeVisible(); + expect(screen.queryByText(/try again/i)).not.toBeInTheDocument(); + // Reconnecting gets the same answer; an update may not. + expect( + screen.queryByRole("button", { name: "Retry connection" }), + ).not.toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: "Check for updates" }), + ); + expect(h.open).toHaveBeenCalledExactlyOnceWith({ + version: 1, + kind: "settings", + section: "updates", + }); + expect(h.retryConnection).not.toHaveBeenCalled(); +}); + it("waits for the account choice before showing the chooser", async () => { const choice = deferred(); const h = await bestieFixture(native.invoke, { diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx index 06b8ca48e2..436d6ea0e5 100644 --- a/src/bundled/bestie/index.tsx +++ b/src/bundled/bestie/index.tsx @@ -7,6 +7,11 @@ import type { ConversationExtensions } from "../../features/conversation/contrac import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; import type { Agents } from "../../features/agents/service"; import type { RelayData } from "../../features/relay/service"; +import { + connectionFailureAction, + connectionFailureText, + openUpdatesWith, +} from "../../features/relay/ConnectionFailed"; import { useRelayConnection } from "../../features/relay/react"; import { BestieIcon } from "../../shared/design-system/icons"; import { Button } from "../../shared/design-system/ui/Button"; @@ -128,14 +133,15 @@ function BestieDestination({ description={ connection.status === "connecting" ? "Your Bestie opens once this community is connected." - : (connection.error ?? - "Choose a community to continue with your Bestie.") - } - action={ - connection.status === "error" ? ( - - ) : undefined + : connection.error + ? connectionFailureText(connection) + : "Choose a community to continue with your Bestie." } + action={connectionFailureAction({ + connection, + relay, + openUpdates: openUpdatesWith(props.navigation), + })} /> ); diff --git a/src/bundled/bestie/testing.ts b/src/bundled/bestie/testing.ts index 779ff2769c..02dd2a0840 100644 --- a/src/bundled/bestie/testing.ts +++ b/src/bundled/bestie/testing.ts @@ -322,6 +322,7 @@ export async function bestieFixture( const notify = () => { for (const listener of [...listeners]) listener(); }; + const retryConnection = vi.fn(); const relay = { snapshot: () => snapshot, subscribe(listener: () => void) { @@ -330,7 +331,7 @@ export async function bestieFixture( listeners.delete(listener); }; }, - retry() {}, + retry: retryConnection, disconnect() {}, async clearCache() {}, }; @@ -446,6 +447,13 @@ export async function bestieFixture( query, viewer: identity.pubkey, agent: remote.pubkey, + /** The relay connection's Retry connection. */ + retryConnection, + /** Moves the relay connection on, as its service would. */ + updateRelay(patch: Partial) { + snapshot = { ...snapshot, ...patch } as RelaySnapshot; + notify(); + }, /** Each request the native session forwarded to Builderlab. */ forwarded: federated.request, /** The bodies of what Builderlab was sent at `path`, in order. */ diff --git a/src/bundled/channels/ChannelBody.test.tsx b/src/bundled/channels/ChannelBody.test.tsx new file mode 100644 index 0000000000..3e56467108 --- /dev/null +++ b/src/bundled/channels/ChannelBody.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { ReadError } from "../../features/relay/errors"; +import { ApiPaused } from "../../features/relay/http-admission"; +import type { WindowFailure } from "../../features/relay/window-failure"; +import { UPDATES_LINK } from "../../features/messages/history-failure"; +import { ChannelBody } from "./ChannelBody"; + +const conversation = { + ensure: vi.fn(), + onOpenLink: vi.fn((_url: string) => true), +}; +let failure: WindowFailure | undefined; +let recovers = false; +vi.mock("../../features/conversation/Conversation", () => ({ + useConversation: () => ({ + session: { channels: { ensure: conversation.ensure } }, + channelId: "alpha", + ownSoloConversation: false, + onOpenLink: conversation.onOpenLink, + }), + ConversationTimeline: () => null, +})); +vi.mock("../../features/relay/react", () => ({ + useChannelWindow: () => ({ + channelId: "alpha", + status: "error", + rows: [], + hasMore: false, + loadingOlder: false, + error: failure, + }), +})); +vi.mock("../../features/relay/RecoveryNotice", () => ({ + useRecoveryOwns: () => recovers, +})); +vi.mock("../../features/developer/client-metrics", () => ({ + clientMetrics: { + channelMounted() {}, + channelUnmounted() {}, + channelRendered() {}, + channelEmpty() {}, + }, +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + recovers = false; +}); + +function show(error: WindowFailure) { + failure = error; + render(); + return screen.getByRole("alert"); +} + +it.each([ + { + name: "a server error", + error: new ReadError("unavailable", "Relay read failed (503)", 503), + }, + { name: "a native timeout", error: "network-timeout" }, + { name: "an unknown failure", error: new Error("boom") }, +])("says messages didn’t load and offers Try again for $name", ({ error }) => { + const alert = show(error); + expect(alert).toHaveTextContent("Messages didn’t load."); + expect(alert).not.toHaveTextContent(/relay|503|network-timeout|boom/i); + expect( + screen.queryByRole("button", { name: "Check for updates" }), + ).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(conversation.ensure).toHaveBeenCalledWith("alpha"); +}); + +it("says a busy Buzz can try again", () => { + const alert = show(new ApiPaused(3000)); + expect(alert).toHaveTextContent( + "Messages didn’t load. Buzz is busy. Try again in a few seconds.", + ); + expect(screen.getByRole("button", { name: "Try again" })).toBeVisible(); +}); + +it("offers only Check for updates for an answer Buzz can't use", () => { + const alert = show( + new ReadError("invalid-response", "Relay response is not an event array"), + ); + expect(alert).toHaveTextContent("Messages didn’t load."); + expect(alert).not.toHaveTextContent(/event array|relay/i); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Check for updates" })); + expect(conversation.onOpenLink).toHaveBeenCalledExactlyOnceWith(UPDATES_LINK); +}); + +it("offers no button for a refusal and shows no raw text or Details", () => { + const alert = show(new ReadError("denied", "Relay read failed (403)", 403)); + expect(alert).toHaveTextContent( + "Messages didn’t load. You don’t have access to this conversation.", + ); + expect(alert).not.toHaveTextContent(/relay|403/i); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + expect( + screen.queryByRole("button", { name: "Check for updates" }), + ).toBeNull(); + expect(screen.queryByText("Details")).toBeNull(); +}); + +it("leaves the empty timeline to the recovery notice while recovery retries", () => { + recovers = true; + failure = new ReadError("unavailable", "Relay read failed (503)", 503); + render(); + expect(screen.queryByRole("alert")).toBeNull(); +}); diff --git a/src/bundled/channels/ChannelBody.tsx b/src/bundled/channels/ChannelBody.tsx index d94cfe6d2d..41d0278af8 100644 --- a/src/bundled/channels/ChannelBody.tsx +++ b/src/bundled/channels/ChannelBody.tsx @@ -9,6 +9,10 @@ import { useChannelWindow } from "../../features/relay/react"; import { useRecoveryOwns } from "../../features/relay/RecoveryNotice"; import { headRecovering } from "../../features/relay/live-recovery"; import { clientMetrics } from "../../features/developer/client-metrics"; +import { + historyFailure, + UPDATES_LINK, +} from "../../features/messages/history-failure"; import { Button } from "../../shared/design-system/ui/Button"; import styles from "./Channels.module.css"; @@ -43,6 +47,7 @@ export const ChannelBody = memo(function ChannelBody({ session: queries, channelId, ownSoloConversation: solo, + onOpenLink, } = useConversation(); // ChannelWorkspace already keys this lifetime by viewer/scope/generation. const continuityKey = useId(); @@ -92,21 +97,30 @@ export const ChannelBody = memo(function ChannelBody({ }, [cached, navigation, window.status]); // While recovery retries this timeline's head read, its notice is the one // report, after the quiet delay; the empty timeline waits. - if (window.status === "error" && !window.rows.length && !headRecovers) + if (window.status === "error" && !window.rows.length && !headRecovers) { + const failure = historyFailure(window.error, "Messages didn’t load."); return (
-

{window.error}

- +

{failure.text}

+ {failure.retry && ( + + )} + {failure.update && ( + + )}
); + } if (holding || (window.status !== "ready" && !window.rows.length)) return (
)}
- ) : session.status === "error" && session.denied ? ( - // The community refused this account. Its notice, on every page, - // says what fixes it and offers that; this only says why it's empty. -
- } - title={communityRefusalTitle(session.refusal)} - /> -
) : session.status === "error" && - (!session.retrying || visitSettled) ? ( + (session.denied || !session.retrying || visitSettled) ? ( // Usually a failed visit's card covers this page, but a visit that // already opened from cached content keeps it: say so plainly. -
- } - title="Can’t connect" - description={ - session.retrying - ? "Check your connection. We’ll keep trying." - : "Check your connection, then try again." - } - action={ - - } - /> -
+ + void navigator.open({ + version: 1, + kind: "settings", + section: "updates", + }) + } + /> ) : ( // Only real connection work animates.
diff --git a/src/bundled/channels/useChannelPanels.tsx b/src/bundled/channels/useChannelPanels.tsx index 191cf55532..bb3d599890 100644 --- a/src/bundled/channels/useChannelPanels.tsx +++ b/src/bundled/channels/useChannelPanels.tsx @@ -14,6 +14,7 @@ import type { import { Panel } from "../../shared/design-system/ui/Panel"; import { PanelDock } from "../../features/panels/PanelDock"; import { PanelView } from "../../features/panels/PanelView"; +import { usePluginOwners } from "../../features/pages/ContributionFailure"; import styles from "./Channels.module.css"; const launcherOrder = (key: string) => @@ -59,6 +60,7 @@ export function useChannelPanels( fallbackFocus?: RefObject | undefined; }>, ) { + const owners = usePluginOwners(); const available = useSyncExternalStore( panels.subscribe, panels.snapshot, @@ -205,6 +207,7 @@ export function useChannelPanels( > h.failConnection("relay offline")); render(previewAt(h)); - expect(await screen.findByText("relay offline")).toBeInTheDocument(); + expect( + await screen.findByText("Check your connection, then try again."), + ).toBeInTheDocument(); + expect(screen.queryByText("relay offline")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Retry connection" })); expect(h.retryConnection).toHaveBeenCalledOnce(); // A refusal is the community's, not the connection's: its notice on every @@ -828,6 +831,35 @@ it("retries the card's connection, activity and read-state failures", async () = ).not.toBeInTheDocument(); }); +it("offers Check for updates, not Retry connection, for an answer Buzz can't use", async () => { + const h = fixture(); + const unusable = { + kind: "unusable", + details: "ReadError: Relay response is not an event array", + } as const; + act(() => h.failConnection(unusable.details, { error: unusable })); + const sentence = "Buzz couldn’t connect to this community."; + for (const view of [h.view, previewAt(h)]) { + const shown = render(view); + expect(await screen.findByText(sentence)).toBeInTheDocument(); + expect( + screen.queryByText(/event array|try again/i), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Retry connection" }), + ).not.toBeInTheDocument(); + h.open.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Check for updates" })); + expect(h.open).toHaveBeenCalledExactlyOnceWith({ + version: 1, + kind: "settings", + section: "updates", + }); + shown.unmount(); + } + expect(h.retryConnection).not.toHaveBeenCalled(); +}); + function fixture( options: { failRoster?: boolean; @@ -1458,7 +1490,7 @@ function fixture( connection = { ...connection, status: "error", - error, + error: { kind: "connection", details: error }, generation: 2, ...refused, }; diff --git a/src/bundled/inbox/InboxPage.tsx b/src/bundled/inbox/InboxPage.tsx index 017556a9c2..96508132e0 100644 --- a/src/bundled/inbox/InboxPage.tsx +++ b/src/bundled/inbox/InboxPage.tsx @@ -10,6 +10,11 @@ import { useSyncExternalStore, } from "react"; import type { RelayData } from "../../features/relay/service"; +import { + connectionFailureAction, + connectionFailureText, + openUpdatesWith, +} from "../../features/relay/ConnectionFailed"; import type { RelaySession } from "../../features/relay/session"; import type { InboxItem } from "../../features/relay/inbox"; import type { @@ -229,12 +234,15 @@ export function InboxPage({ : connection.denied ? // Its notice, on every page, offers what fixes it. communityRefusalTitle(connection.refusal) - : (connection.error ?? - "Choose a community to see your activity.")} + : connection.error + ? connectionFailureText(connection) + : "Choose a community to see your activity."}

- {connection.status === "error" && !connection.denied && ( - - )} + {connectionFailureAction({ + connection, + relay, + openUpdates: openUpdatesWith(navigator), + })}
)} diff --git a/src/bundled/inbox/InboxPreview.tsx b/src/bundled/inbox/InboxPreview.tsx index 7e9f464aa5..4fe3dc95e4 100644 --- a/src/bundled/inbox/InboxPreview.tsx +++ b/src/bundled/inbox/InboxPreview.tsx @@ -23,6 +23,11 @@ import { } from "../../features/relay/react"; import { communityRefusalTitle } from "../../features/federated-identity/CommunitySignIn"; import type { RelayData } from "../../features/relay/service"; +import { + connectionFailureAction, + connectionFailureText, + openUpdatesWith, +} from "../../features/relay/ConnectionFailed"; import type { RelaySession } from "../../features/relay/session"; import { ArrowRightIcon, ChecksIcon } from "../../shared/design-system/icons"; import { Button } from "../../shared/design-system/ui/Button"; @@ -80,6 +85,12 @@ export function InboxPreview({ close={close} /> ); + const action = connectionFailureAction({ + connection, + relay, + openUpdates: openUpdatesWith(navigator), + size: "sm", + }); return (

@@ -88,15 +99,11 @@ export function InboxPreview({ : connection.denied ? // Its notice, on every page, offers what fixes it. communityRefusalTitle(connection.refusal) - : (connection.error ?? "Choose a community to see your activity.")} + : connection.error + ? connectionFailureText(connection) + : "Choose a community to see your activity."}

- {connection.status === "error" && !connection.denied && ( -
- -
- )} + {action &&
{action}
}
); } diff --git a/src/bundled/relay-notes/NotesPage.test.tsx b/src/bundled/relay-notes/NotesPage.test.tsx index 7b74666fed..f9d6c12401 100644 --- a/src/bundled/relay-notes/NotesPage.test.tsx +++ b/src/bundled/relay-notes/NotesPage.test.tsx @@ -1286,3 +1286,89 @@ it.each([false, true])( ).toHaveFocus(); }, ); + +function unavailable(snapshot: Partial) { + const current = { generation: 1, ...snapshot } as RelaySnapshot; + const relay = { + snapshot: () => current, + subscribe: () => () => {}, + retry: vi.fn(), + } as unknown as RelayData; + render(); + return relay; +} + +it("says it can't connect, with Try again, when the community session fails", async () => { + const relay = unavailable({ + status: "error", + retrying: true, + error: { + kind: "connection", + details: "ReadError: Relay read failed (502)", + }, + }); + expect( + screen.getByRole("heading", { name: "Can’t connect" }), + ).toBeInTheDocument(); + expect( + screen.getByText("Check your connection. We’ll keep trying."), + ).toBeInTheDocument(); + expect(screen.queryByText(/ReadError|502/)).toBeNull(); + expect(screen.queryByText("Details")).toBeNull(); + expect(screen.queryByText(/Connect to a community/)).toBeNull(); + expect(screen.queryByRole("button", { name: "Retry connection" })).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(relay.retry).toHaveBeenCalledOnce(); +}); + +it("says only that Buzz couldn't connect, beside Check for updates, for an answer it can't use", async () => { + const open = vi.fn(async () => ({ status: "opened" }) as const); + const current = { + generation: 1, + status: "error", + error: { kind: "unusable", details: "ReadError: Relay read failed (400)" }, + } as RelaySnapshot; + const relay = { + snapshot: () => current, + subscribe: () => () => {}, + retry: vi.fn(), + } as unknown as RelayData; + render( + , + ); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent( + "Can’t connectBuzz couldn’t connect to this community.", + ); + // Its only action is Check for updates, so nothing asks to try again. + expect(alert).not.toHaveTextContent(/try again|check your connection/i); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + await userEvent.click( + screen.getByRole("button", { name: "Check for updates" }), + ); + expect(open).toHaveBeenCalledExactlyOnceWith({ + version: 1, + kind: "settings", + section: "updates", + }); + expect(relay.retry).not.toHaveBeenCalled(); +}); + +it("shows the refusal, and no Try again, when the community refused the account", () => { + unavailable({ + status: "error", + denied: true, + refusal: "sign-in", + error: { kind: "refused", details: "Federated sign-in is required" }, + }); + expect(screen.getByRole("heading", { name: /Sign in/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + expect(screen.queryByText(/Federated/)).toBeNull(); +}); + +it("keeps asking for a community only when none is connected", () => { + unavailable({ status: "disconnected" }); + expect(screen.getByRole("status")).toHaveTextContent( + "Connect to a community to read notes.", + ); +}); diff --git a/src/bundled/relay-notes/NotesPage.tsx b/src/bundled/relay-notes/NotesPage.tsx index db82ea2b1e..948b0b4869 100644 --- a/src/bundled/relay-notes/NotesPage.tsx +++ b/src/bundled/relay-notes/NotesPage.tsx @@ -22,6 +22,7 @@ import type { Profile } from "../../features/relay/contracts"; import type { EventData } from "../../features/relay/events"; import { useRelayConnection } from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; +import { ConnectionFailed } from "../../features/relay/ConnectionFailed"; import type { RelaySession } from "../../features/relay/session"; import { ArrowLeftIcon, @@ -203,16 +204,26 @@ export function NotesPage({ ); return ( -

- {connection.status === "connecting" - ? "Connecting to your community…" - : "Connect to a community to read notes."} -

- {connection.status === "error" && ( - <> -

{connection.error}

- - + {connection.status === "error" ? ( + + void navigator.open({ + version: 1, + kind: "settings", + section: "updates", + })) + } + /> + ) : ( +

+ {connection.status === "connecting" + ? "Connecting to your community…" + : "Connect to a community to read notes."} +

)}
); diff --git a/src/bundled/reminders/RemindersPage.test.tsx b/src/bundled/reminders/RemindersPage.test.tsx index 787ae2d39e..813c264ffc 100644 --- a/src/bundled/reminders/RemindersPage.test.tsx +++ b/src/bundled/reminders/RemindersPage.test.tsx @@ -790,6 +790,61 @@ function staticPage(connection: () => unknown) { ); } +it.each([ + { + failure: "an answer this Buzz can't use", + error: { + kind: "unusable", + details: "ReadError: Relay response is not an event array", + }, + sentence: "Buzz couldn’t connect to this community.", + action: "Check for updates", + }, + { + failure: "a connection that failed", + error: { kind: "connection", details: "TypeError: Failed to fetch" }, + sentence: "Check your connection, then try again.", + action: "Retry connection", + }, +] as const)( + "offers what the other pages do beside $failure", + ({ error, sentence, action }) => { + const connection = { status: "error", error }; + const retry = vi.fn(); + const open = vi.fn(() => Promise.resolve({ status: "opened" })); + render( + connection, + subscribe: () => () => {}, + retry, + } as unknown as RelayData + } + navigator={{ open } as unknown as Navigation} + clock={{ subscribe: () => () => {}, read: () => now }} + />, + ); + expect(screen.getByRole("status")).toHaveTextContent(sentence); + // Only one of them: retrying an answer Buzz can't use gets the same one. + expect( + screen.getAllByRole("button").map((button) => button.textContent), + ).toEqual([action]); + fireEvent.click(screen.getByRole("button", { name: action })); + if (error.kind === "unusable") { + expect(open).toHaveBeenCalledExactlyOnceWith({ + version: 1, + kind: "settings", + section: "updates", + }); + expect(retry).not.toHaveBeenCalled(); + } else { + expect(retry).toHaveBeenCalledOnce(); + expect(open).not.toHaveBeenCalled(); + } + }, +); + it("keeps focus on a control that claims it in the commit that removes Retry", () => { // A store that changes only when the test re-renders, so the failure clears // and the claim opens in one commit. diff --git a/src/bundled/reminders/RemindersPage.tsx b/src/bundled/reminders/RemindersPage.tsx index c856494c05..2b07f2c1af 100644 --- a/src/bundled/reminders/RemindersPage.tsx +++ b/src/bundled/reminders/RemindersPage.tsx @@ -19,6 +19,11 @@ import { useReducedMotion, } from "motion/react"; import type { RelayData } from "../../features/relay/service"; +import { + connectionFailureAction, + connectionFailureText, + openUpdatesWith, +} from "../../features/relay/ConnectionFailed"; import type { RelaySession } from "../../features/relay/session"; import type { Reminder, Reminders } from "../../features/relay/reminders"; import type { Navigation } from "../../features/navigation/controller"; @@ -135,7 +140,7 @@ export function RemindersPage({ }; const status = !reminders ? connection.status === "error" - ? (connection.error ?? "Could not connect.") + ? connectionFailureText(connection) : "Connect to a community to see your reminders." : state.status === "loading" && !state.hydrated ? "Loading reminders…" @@ -193,9 +198,18 @@ export function RemindersPage({ )} {status || !reminders ? ( -

- {status} -

+
+

{status}

+ {/* Beside a failed connection, what the other pages offer: + Retry connection, or Check for updates for an answer this + Buzz can't use. */} + {!reminders && + connectionFailureAction({ + connection, + relay, + openUpdates: openUpdatesWith(navigator), + })} +
) : (failed && !state.hydrated && !state.reminders.length) || connection.status !== "ready" ? null : ( { screen.getByText("Unreadable configuration").closest("button"), ).toBeNull(); }); + +function disconnected(snapshot: Partial) { + const current = { generation: 1, ...snapshot } as RelaySnapshot; + const relay = { + snapshot: () => current, + subscribe: () => () => {}, + retry: vi.fn(), + } as unknown as RelayData; + render(); + return relay; +} + +it("says it can't connect, with Try again, when the community session fails", async () => { + const relay = disconnected({ + status: "error", + error: { + kind: "connection", + details: "ReadError: Relay read failed (502)", + }, + }); + expect( + screen.getByRole("heading", { name: "Can’t connect" }), + ).toBeInTheDocument(); + expect( + screen.getByText("Check your connection, then try again."), + ).toBeInTheDocument(); + expect(screen.queryByText(/ReadError|502/)).toBeNull(); + expect(screen.queryByText("Details")).toBeNull(); + expect(screen.queryByText(/Connect to a community/)).toBeNull(); + expect(screen.queryByRole("button", { name: "Retry connection" })).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(relay.retry).toHaveBeenCalledOnce(); +}); + +it("says only that Buzz couldn't connect, beside Check for updates, for an answer it can't use", async () => { + const open = vi.fn(async () => ({ status: "opened" }) as const); + const current = { + generation: 1, + status: "error", + error: { kind: "unusable", details: "ReadError: Relay read failed (400)" }, + } as RelaySnapshot; + const relay = { + snapshot: () => current, + subscribe: () => () => {}, + retry: vi.fn(), + } as unknown as RelayData; + render( + , + ); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent( + "Can’t connectBuzz couldn’t connect to this community.", + ); + // Its only action is Check for updates, so nothing asks to try again. + expect(alert).not.toHaveTextContent(/try again|check your connection/i); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + await userEvent.click( + screen.getByRole("button", { name: "Check for updates" }), + ); + expect(open).toHaveBeenCalledExactlyOnceWith({ + version: 1, + kind: "settings", + section: "updates", + }); + expect(relay.retry).not.toHaveBeenCalled(); +}); + +it("shows the refusal, and no Try again, when the community refused the account", () => { + disconnected({ + status: "error", + denied: true, + refusal: "access", + error: { kind: "refused", details: "restricted: authorization denied" }, + }); + expect( + screen.getByRole("heading", { + name: "You don’t have access to this community", + }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.queryByText(/restricted/)).toBeNull(); +}); + +it("keeps asking for a community only when none is connected", () => { + disconnected({ status: "disconnected" }); + expect( + screen.getByText("Connect to a community to browse workflows."), + ).toBeInTheDocument(); + expect(screen.queryByRole("button")).toBeNull(); +}); diff --git a/src/bundled/workflows/WorkflowsPage.tsx b/src/bundled/workflows/WorkflowsPage.tsx index d84da29c1b..807c7a6d71 100644 --- a/src/bundled/workflows/WorkflowsPage.tsx +++ b/src/bundled/workflows/WorkflowsPage.tsx @@ -9,6 +9,8 @@ import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; import type { WorkflowDefinition } from "../../features/workflows/types"; import { useChannelList, useRelayConnection } from "../../features/relay/react"; +import { ConnectionFailed } from "../../features/relay/ConnectionFailed"; +import type { Navigation } from "../../features/navigation/controller"; import { ArrowsClockwiseIcon, LightningIcon, @@ -36,7 +38,14 @@ import { PanelWorkspace } from "../../features/panels/PanelWorkspace"; import { usePanelSplit } from "../../features/panels/usePanelSplit"; import "./workflows.css"; -export function WorkflowsPage({ relay }: { relay: RelayData }) { +export function WorkflowsPage({ + relay, + navigator, +}: { + relay: RelayData; + /** Opens Settings → Software updates when the community can't be used. */ + navigator?: Navigation | undefined; +}) { const connection = useRelayConnection(relay); return connection.status === "ready" ? ( -
-

- {connection.status === "connecting" - ? "Connecting to your community…" - : "Connect to a community to browse workflows."} -

- {connection.status === "error" && ( - <> -

{connection.error}

- - - )} -
+ {connection.status === "error" ? ( +
+ + void navigator.open({ + version: 1, + kind: "settings", + section: "updates", + })) + } + /> +
+ ) : ( +
+

+ {connection.status === "connecting" + ? "Connecting to your community…" + : "Connect to a community to browse workflows."} +

+
+ )} ); } diff --git a/src/bundled/workflows/index.tsx b/src/bundled/workflows/index.tsx index 895f3d8877..f07e5beb37 100644 --- a/src/bundled/workflows/index.tsx +++ b/src/bundled/workflows/index.tsx @@ -1,6 +1,6 @@ import type { PluginModule } from "../../plugins/api"; import { WorkflowsPage } from "./WorkflowsPage"; -export const inject = ["pages", "relay"]; +export const inject = ["pages", "relay", "navigation"]; export const apply: PluginModule["apply"] = (ctx) => { const relay = ctx.relay; ctx.pages.register({ @@ -8,6 +8,6 @@ export const apply: PluginModule["apply"] = (ctx) => { title: "Workflows", layout: "workspace", primary: true, - component: () => , + component: () => , }); }; diff --git a/src/features/agents/inventory.test.ts b/src/features/agents/inventory.test.ts index 700ea93b06..d1e0217d90 100644 --- a/src/features/agents/inventory.test.ts +++ b/src/features/agents/inventory.test.ts @@ -109,7 +109,7 @@ it("quietly retries a transient relay inventory failure before merging", async ( const identity = { pubkey: "b".repeat(64), name: "Relay" }; const relay = vi .fn() - .mockRejectedValueOnce(new TypeError("offline")) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) .mockResolvedValue({ definitions: [], identities: [identity] }); const pending = inventoryReader( async () => ({ definitions: [], identities: [] }), diff --git a/src/features/agents/service-testing.ts b/src/features/agents/service-testing.ts index 00942648ac..cbfc263bc6 100644 --- a/src/features/agents/service-testing.ts +++ b/src/features/agents/service-testing.ts @@ -254,7 +254,12 @@ export function fakeRelay(origin = "https://relay.example.test") { }, /** Fails the connection, as a first connect that timed out does. */ fail() { - snapshot = { status: "error", generation: 4, session, error: "down" }; + snapshot = { + status: "error", + generation: 4, + session, + error: { kind: "timeout", details: "down" }, + }; for (const listener of changes) listener(); }, /** Replaces the session, as after the transport was lost. */ diff --git a/src/features/conversation/ConversationHost.tsx b/src/features/conversation/ConversationHost.tsx index ebf37276a7..18c135f8b0 100644 --- a/src/features/conversation/ConversationHost.tsx +++ b/src/features/conversation/ConversationHost.tsx @@ -10,6 +10,7 @@ import { import { Dialog } from "../../shared/design-system/ui/Dialog"; import { buzzLinkTarget } from "../navigation/buzz-links"; import { PanelView } from "../panels/PanelView"; +import { usePluginOwners } from "../pages/ContributionFailure"; import { panelOpening, type PanelOpening } from "../panels/service"; import type { ThreadPanelProps } from "../messages/ThreadPanel"; import { useConversationPresentation } from "./ConversationPresentation"; @@ -60,6 +61,7 @@ export function ConversationHost({ children, }: ConversationHostProps) { const presented = useConversationPresentation(); + const owners = usePluginOwners(); const presentation = useRef(presented); const trigger = useRef(null); useEffect(() => { @@ -174,6 +176,7 @@ export function ConversationHost({ > { if (shown.current === overlay) setOverlay(undefined); diff --git a/src/features/direct-messages/NewMessage.recovery.test.tsx b/src/features/direct-messages/NewMessage.recovery.test.tsx index 919da4c9d2..e98034a6c4 100644 --- a/src/features/direct-messages/NewMessage.recovery.test.tsx +++ b/src/features/direct-messages/NewMessage.recovery.test.tsx @@ -17,6 +17,7 @@ import { import userEvent from "@testing-library/user-event"; import { useChannelList } from "../relay/react"; import { createRelaySession } from "../relay/session"; +import { ReadError } from "../relay/errors"; import { keypair, profile, @@ -200,6 +201,7 @@ function setup( return owner; }; const onStarted = vi.fn(); + const navigate = vi.fn(); function Draft({ owner, extensions, @@ -229,7 +231,7 @@ function setup( scope, }), }, - navigation: { open: vi.fn() }, + navigation: { open: navigate }, } as unknown as ConversationHostServices } onOpenThread={vi.fn()} @@ -251,6 +253,7 @@ function setup( }; return { storage, + navigate, query, events, openDirectMessage, @@ -1123,11 +1126,10 @@ it.each([false, true])( if (filters.some((filter) => filter.kinds?.includes(9))) { started = true; await pending; + if (broken) + throw new ReadError("unavailable", "Relay read failed (503)", 503); } - const result = await read(filters); - return broken && filters.some((filter) => filter.kinds?.includes(9)) - ? result.filter((event) => event.kind === 9) - : result; + return read(filters); }); const owner = t.create(); t.mount(owner); @@ -1136,7 +1138,12 @@ it.each([false, true])( expect(owner.session.channels.window(channel).status).toBe("error"), ); await t.user.type(screen.getByRole("textbox"), "Keep this draft"); - const retry = screen.getByRole("button", { name: "Retry messages" }); + expect(screen.getByRole("alert")).toHaveTextContent( + "Messages didn’t load.", + ); + expect(screen.getByRole("alert")).not.toHaveTextContent(/relay|503/i); + expect(screen.queryByText("Details")).toBeNull(); + const retry = screen.getByRole("button", { name: "Try again" }); const recipients = screen.getByRole("combobox", { name: "Message recipients", }); @@ -1191,6 +1198,32 @@ it.each([false, true])( }, ); +it("says an unusable history answer can't be retried and offers updates instead", async () => { + const t = setup(); + const read = t.query.getMockImplementation(); + assert.exists(read); + // A history answer without its window bounds: the same read fails every time. + t.query.mockImplementation(async (filters) => { + const result = await read(filters); + return filters.some((filter) => filter.kinds?.includes(9)) + ? result.filter((event) => event.kind === 9) + : result; + }); + const owner = t.create(); + t.mount(owner); + await t.user.click(await screen.findByRole("option", { name: "Avery" })); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Messages didn’t load."); + expect(alert).not.toHaveTextContent(/relay|bounds/i); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + await t.user.click(screen.getByRole("button", { name: "Check for updates" })); + expect(t.navigate).toHaveBeenCalledExactlyOnceWith({ + version: 1, + kind: "settings", + section: "updates", + }); +}); + const photo = { name: "photo.png", url: `https://relay.example/media/${"f".repeat(64)}.png`, diff --git a/src/features/direct-messages/NewMessage.tsx b/src/features/direct-messages/NewMessage.tsx index 9cf8164a02..2fef84833e 100644 --- a/src/features/direct-messages/NewMessage.tsx +++ b/src/features/direct-messages/NewMessage.tsx @@ -31,6 +31,7 @@ import { MessageComposer } from "../messages/MessageComposer"; import { mentionDraft, type MentionDraft } from "../messages/mention-draft"; import { clearView, readView, writeView } from "../../shared/view-state"; import { Button } from "../../shared/design-system/ui/Button"; +import { historyFailure } from "../messages/history-failure"; import { useVerifiedNames, withVerifiedNames, @@ -631,14 +632,15 @@ function ExistingDiscussion({ if (document.activeElement === retry.current) onRecovered(); setRecovering(false); }, [waiting, recovering, onRecovered]); - if (waiting || recovering) + if (waiting || recovering) { + const failure = + window.status === "error" + ? historyFailure(window.error, "Messages didn’t load.") + : undefined; return ( -
-

{window.status === "error" ? window.error : "Loading messages…"}

- {(window.status === "error" || recovering) && ( +
+

{failure ? failure.text : "Loading messages…"}

+ {(failure?.retry || recovering) && ( + )} + {failure?.update && ( + )}
); + } return ( Promise) { let fail = false; let gate: Promise | undefined; let historyGate: Promise | undefined; + let historyError: Error | undefined; let historyStarted = () => {}; const published: RelayEvent[] = []; const publish = vi.fn(async (event: RelayEvent) => { @@ -116,6 +118,7 @@ function fixture(upload?: () => Promise) { ( await Promise.all( filters.map(async (filter) => { + if (historyError && filter.top_level) throw historyError; if ( historyGate && (filter.top_level || @@ -249,6 +252,9 @@ function fixture(upload?: () => Promise) { }, }; }, + failHistory: (error: Error | undefined) => { + historyError = error; + }, liveMessage: () => { const next = message(other, "room", "New live message", 1000000); events.push(next); @@ -930,6 +936,58 @@ it("opens a cold sent channel preview with an empty composer and locates the mes expect(within(region).queryByText(/could not be found/)).toBeNull(); }); +async function openFailedSentPreview(error: Error) { + const h = fixture(); + h.seedSent(false); + h.failHistory(error); + h.render(); + fireEvent.click(screen.getByRole("tab", { name: "Sent" })); + fireEvent.click( + await screen.findByRole("button", { name: /View message.*Already sent/ }), + ); + const region = screen.getByRole("region", { + name: "Sent message in #Design", + }); + // The preview's own notice; a loaded timeline may add its edge notice. + const notice = (await within(region).findAllByRole("alert")).find( + (alert) => alert.tagName === "P", + )?.parentElement; + assert.exists(notice); + return { h, notice }; +} + +it("says a transient history failure didn’t load and retries on Try again", async () => { + const { h, notice } = await openFailedSentPreview( + new ReadError("unavailable", "Relay read failed (503)", 503), + ); + expect(within(notice).getByRole("alert")).toHaveTextContent( + "Messages didn’t load.", + ); + expect(within(notice).getByRole("alert")).not.toHaveTextContent(/relay|503/i); + expect(within(notice).queryByText("Details")).toBeNull(); + h.failHistory(undefined); + fireEvent.click(within(notice).getByRole("button", { name: "Try again" })); + await waitFor(() => expect(notice).not.toBeInTheDocument()); +}); + +it("offers only Check for updates when the history answer can never be used", async () => { + const { h, notice } = await openFailedSentPreview( + new ReadError("invalid-response", "Relay response is not an event array"), + ); + expect(within(notice).getByRole("alert")).toHaveTextContent( + "Messages didn’t load.", + ); + expect( + within(notice).queryByRole("button", { name: "Try again" }), + ).toBeNull(); + fireEvent.click( + within(notice).getByRole("button", { name: "Check for updates" }), + ); + expect(h.open).toHaveBeenCalledWith( + expect.objectContaining({ kind: "settings", section: "updates" }), + ); +}); + it("reveals and highlights an off-screen Sent reply in its visible preview", async () => { const h = fixture(); const sent = h.seedSent(true); diff --git a/src/features/drafts-sent/DraftPreview.tsx b/src/features/drafts-sent/DraftPreview.tsx index cf02e14e00..bc828afaf3 100644 --- a/src/features/drafts-sent/DraftPreview.tsx +++ b/src/features/drafts-sent/DraftPreview.tsx @@ -27,6 +27,7 @@ import { useChannelWindow } from "../relay/react"; import { readView, viewRevision } from "../../shared/view-state"; import { writeClipboardText } from "../../shared/clipboard"; import { Button } from "../../shared/design-system/ui/Button"; +import { historyFailure, UPDATES_LINK } from "../messages/history-failure"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { PanelHeader, @@ -304,8 +305,12 @@ function DraftChannel({ onSaved(): void; onSend(id: string): void; }) { - const { session, channelId } = useConversation(); + const { session, channelId, onOpenLink } = useConversation(); const channelWindow = useChannelWindow(session.channels, channelId); + const failure = + channelWindow.status === "error" || channelWindow.error + ? historyFailure(channelWindow.error, "Messages didn’t load.") + : undefined; const [seekError, setSeekError] = useState(false); const [noticeHeight, setNoticeHeight] = useState(0); const [outgoing, setOutgoing] = useState(); @@ -375,17 +380,24 @@ function DraftChannel({ inlineTarget={reveal} /> )} - {channelWindow.status === "error" || channelWindow.error ? ( + {failure ? (

- {channelWindow.error ?? "Could not load conversation."} + {failure.text}

- + {failure.retry && ( + + )} + {failure.update && ( + + )}
) : ( !channelWindow.rows.length && diff --git a/src/features/huddle/discussion.ts b/src/features/huddle/discussion.ts index 7fe4f62cbe..4428103ad2 100644 --- a/src/features/huddle/discussion.ts +++ b/src/features/huddle/discussion.ts @@ -4,6 +4,7 @@ import type { Attachment, ChannelMessage } from "../relay/contracts"; import type { Delivery } from "../relay/outbox"; import { formatPublicKey } from "../../shared/identity/public-key"; import { avatarMedia, avatarSource } from "../../shared/avatar-source"; +import { userCopy } from "../relay/user-copy"; export type HuddleDiscussion = { composer?: string | undefined; @@ -148,7 +149,12 @@ export function createHuddleDiscussion( : !resolved || window.status !== "ready" ? "loading" : "ready", - error: error ?? window.error, + error: + error ?? + (window.error + ? userCopy(window.error, "Couldn’t load this Huddle conversation.") + .text + : undefined), writable: writable(), sending, sent, diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx index f8ca1913f8..7a431cf3c1 100644 --- a/src/features/messages/ChannelTimeline.test.tsx +++ b/src/features/messages/ChannelTimeline.test.tsx @@ -18,6 +18,10 @@ import { import type { LiveCallbacks } from "../relay/live"; import type { RelaySession } from "../relay/session"; import type { ChannelMessage, ChannelWindow } from "../relay/contracts"; +import { ReadError } from "../relay/errors"; +import { ApiPaused } from "../relay/http-admission"; +import type { WindowFailure } from "../relay/window-failure"; +import { UPDATES_LINK } from "./history-failure"; // Boundary test, not a browser renderer. Capture this production component's // effects/refs and invoke its returned DOM handlers. Deliberately stale Virtua @@ -134,7 +138,10 @@ function setup({ hasMore = false, loadingOlder = false, historyLimited = false, - error = undefined as string | undefined, + error = undefined as WindowFailure | undefined, + /** Where `error` came from: an older page unless a test says otherwise. */ + errorFrom = "older" as "older" | "elsewhere", + openLink = (() => false) as (url: string) => boolean, recovering = false, initial = undefined as unknown, mounted = [] as { id: string; y: number }[], @@ -259,6 +266,9 @@ function setup({ loadingOlder: loadingOlder || accepted, historyLimited, error, + ...(error && errorFrom === "older" + ? { errorSource: "older" as const } + : {}), }); const loadOlder = vi.fn(() => { if (!blocked && !accepted && !loadingOlder) { @@ -321,12 +331,15 @@ function setup({ status, freshness, error, + ...(error && errorFrom === "older" + ? { errorSource: "older" as const } + : {}), rows, hasMore, loadingOlder, historyLimited, }, - onOpenLink: () => false, + onOpenLink: openLink, revealMessageId, }); if (key !== null && key !== scoped.key) { @@ -2072,8 +2085,8 @@ it.each(["button", "scroll"] as const)( historyControl, error: "Read failed", }); - expect(failed.alert()).toBe("Read failed"); - expect(failed.historyButton()).toBe("Retry older messages"); + expect(failed.alert()).toBe("Older messages didn’t load."); + expect(failed.historyButton()).toBe("Try again"); failed.unmount(); // Recovery does not retry older history, and paging stops on a failed // read, so hiding Retry would strand paging until recovery settles. @@ -2084,11 +2097,92 @@ it.each(["button", "scroll"] as const)( recovering: true, }); expect(recovering.alert()).toBeUndefined(); - expect(recovering.historyButton()).toBe("Retry older messages"); + expect(recovering.historyButton()).toBe("Try again"); recovering.unmount(); }, ); +it.each([ + { + name: "a paused queue", + error: new ApiPaused(3000), + alert: + "Older messages didn’t load. Buzz is busy. Try again in a few seconds.", + }, + { + name: "a native timeout", + error: "network-timeout", + alert: "Older messages didn’t load.", + }, + { + name: "a server error", + error: new ReadError("unavailable", "Relay read failed (503)", 503), + alert: "Older messages didn’t load.", + }, +])( + "says older messages didn’t load and offers Try again for $name", + ({ error, alert }) => { + const h = setup({ hasMore: true, error }); + expect(h.alert()).toBe(alert); + expect(h.historyButton()).toBe("Try again"); + expect(JSON.stringify(h.alert())).not.toMatch(/Relay|503|network-timeout/); + h.retry(); + expect(h.loadOlder).toHaveBeenCalledExactlyOnceWith("channel"); + h.unmount(); + }, +); + +it("offers only Check for updates when older messages can never load here", () => { + const openLink = vi.fn(() => true); + const h = setup({ + hasMore: true, + error: new ReadError("unavailable", "Relay read failed (400)", 400), + openLink, + }); + expect(h.alert()).toBe("Older messages didn’t load."); + expect(h.historyButton()).toBe("Check for updates"); + h.retry(); + expect(h.loadOlder).not.toHaveBeenCalled(); + expect(openLink).toHaveBeenCalledExactlyOnceWith(UPDATES_LINK); + h.unmount(); +}); + +it("offers no button when the community refused the read", () => { + const h = setup({ + hasMore: true, + error: new ReadError("denied", "Relay read failed (403)", 403), + }); + expect(h.alert()).toBe( + "Older messages didn’t load. You don’t have access to this conversation.", + ); + expect(h.hasHistoryButton()).toBe(false); + h.unmount(); +}); + +it.each([ + ["a head revalidation", "Head revalidation failed"], + ["a cached-metadata resolve", new ReadError("unavailable", "down", 503)], + ["a newer-page seek", new ReadError("invalid-response", "bad page")], + ["a refused head read", new ReadError("denied", "Relay read failed (403)")], +] as const)( + "keeps older paging for %s failure and does not blame older messages", + (_, error) => { + for (const historyControl of ["button", "scroll"] as const) { + const h = setup({ + hasMore: true, + historyControl, + error, + errorFrom: "elsewhere", + }); + expect(h.alert()).toBeUndefined(); + expect(h.historyButton()).toBe("Load older messages"); + h.retry(); + expect(h.loadOlder).toHaveBeenCalledExactlyOnceWith("channel"); + h.unmount(); + } + }, +); + it("pages Solo chat history on scroll without an initial button and keeps error recovery", () => { const h = setup({ hasMore: true, historyControl: "scroll" }); expect(h.hasHistoryButton()).toBe(false); diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 43427b312e..392ef3c01c 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -57,6 +57,7 @@ import { messageViewKey } from "./view-key"; import { useKnownAgentPubkeys } from "../agents/use-known"; import { JumpToLatestButton } from "./JumpToLatestButton"; import { moveMessageSelection } from "./message-selection"; +import { historyFailure, UPDATES_LINK } from "./history-failure"; import { useMessageSelection } from "./use-message-selection"; import { useMessageVisit } from "./MessageManagement"; @@ -706,8 +707,14 @@ function Timeline({ // While the connection recovers, its notice is the one report, so the // history edge does not repeat the reason. Recovery does not retry older // history, and paging stops on a failed read, so Retry stays either way. + // Only an older page's failure is reported here; a head, catch-up or seek + // failure leaves older paging as it was. const { recovering } = useRecovery(queries.recovery); - const edgeError = recovering ? undefined : window.error; + const failure = + window.error && window.errorSource === "older" + ? historyFailure(window.error, "Older messages didn’t load.") + : undefined; + const edgeError = recovering ? undefined : failure; // Keep pagination space through EOF so removing its control cannot change // Virtua's start margin in the same measurement cycle as a prepend. const [reserveHistorySpace, setReserveHistorySpace] = useState(false); @@ -1852,12 +1859,21 @@ function Timeline({ className={styles.edge} data-history-space={reserveHistorySpace || undefined} > - {edgeError && {edgeError}} + {edgeError && {edgeError.text}} + {edgeError?.update && ( + + )} {window.historyLimited ? ( History window limit reached ) : window.hasMore && queries.channels.cachedOnly ? ( You can load older messages once Buzz reconnects. - ) : window.hasMore && (historyControl === "button" || window.error) ? ( + ) : window.hasMore && + (failure + ? failure.retry + : // Any failed read stops automatic paging, so offer the button. + historyControl === "button" || !!window.error) ? ( ) : window.loadingOlder ? ( diff --git a/src/features/messages/MediaReviewViewer.comments.test.tsx b/src/features/messages/MediaReviewViewer.comments.test.tsx index 3ae894a0fb..00d87779ca 100644 --- a/src/features/messages/MediaReviewViewer.comments.test.tsx +++ b/src/features/messages/MediaReviewViewer.comments.test.tsx @@ -21,6 +21,8 @@ import type { RelayEvent } from "../relay/events"; import { createRelaySession } from "../relay/session"; import { keypair, message, metadata, roster, signed } from "../relay/testing"; import { MediaReviewViewer } from "./MediaReviewViewer"; +import { ReadError } from "../relay/errors"; +import { UPDATES_LINK } from "./history-failure"; import { ThreadPanel } from "./ThreadPanel"; import { LinkThreadTabContext } from "../conversation/LinkLabelContext"; @@ -208,6 +210,66 @@ function deferredRead() { return { promise, resolve, reject }; } +it.each([ + ["with its media", (url: string) => url], + // The media and the comments both show the failure; the button shows once. + ["before its media resolves", () => undefined], +])( + "offers one Check for updates, not Try again, when the thread read can never succeed (%s)", + async (_, media) => { + const viewer = keypair(); + const relay = keypair(); + let traffic!: LiveCallbacks; + const item = { + kind: "image" as const, + url: "https://fixture.test/item.png", + }; + const root = message(viewer, "one", "One attachment", 1, [ + ["imeta", `url ${item.url}`, `m ${mime(item.kind)}`], + ]); + const query = vi.fn(async (): Promise => { + throw new ReadError( + "invalid-response", + "Thread window bounds are invalid", + ); + }); + const owner = createRelaySession({ + viewer: viewer.pubkey, + relayAuthor: relay.pubkey, + media, + query, + subscribe(callbacks) { + traffic = callbacks; + return { update() {}, retry() {}, dispose() {} }; + }, + }); + owners.push(owner); + traffic.receive([roster(relay, "one", [viewer.pubkey], 10)]); + const onOpenLink = vi.fn(() => true); + render( + {}} + />, + ); + await screen.findAllByText("This attachment didn’t load."); + expect(screen.queryByText(/Relay|400/)).toBeNull(); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + // One button, as with Try again. + const update = screen.getByRole("button", { name: "Check for updates" }); + fireEvent.click(update); + expect(onOpenLink).toHaveBeenCalledExactlyOnceWith(UPDATES_LINK); + }, +); + it.each([ { firstKind: "image", outcome: "ready" }, { firstKind: "video", outcome: "ready" }, @@ -279,12 +341,13 @@ it.each([ ).toBeEnabled(); await waitFor(() => expect(query).toHaveBeenCalled()); await act(async () => gate?.reject(new Error("thread offline"))); - await screen.findByText("Error: thread offline"); + await screen.findByText("This attachment didn’t load."); + expect(screen.queryByText(/thread offline/)).toBeNull(); fireEvent.click(screen.getByRole("button", { name: nextLabel })); fireEvent.click(screen.getByRole("button", { name: nextLabel })); expect(selectedMedia()).toHaveAttribute("src", last.url); gate = deferredRead(); - fireEvent.click(screen.getByRole("button", { name: "Retry" })); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); expect(selectedMedia()).toHaveAttribute("src", last.url); const edit = (items: typeof attachments, time: number) => signed(viewer, { @@ -326,7 +389,7 @@ it.each([ expect(screen.queryByRole("button", { name: nextLabel })).toBeNull(); gate = deferredRead(); const readsBeforeRetry = query.mock.calls.length; - fireEvent.click(screen.getByRole("button", { name: "Retry" })); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); await waitFor(() => expect(query.mock.calls.length).toBeGreaterThan(readsBeforeRetry), ); diff --git a/src/features/messages/MediaReviewViewer.tsx b/src/features/messages/MediaReviewViewer.tsx index 07f0692e21..1fade91d68 100644 --- a/src/features/messages/MediaReviewViewer.tsx +++ b/src/features/messages/MediaReviewViewer.tsx @@ -11,6 +11,8 @@ import { } from "../../shared/design-system/ui/PanelHeader"; import { Checkbox } from "../../shared/design-system/ui/Checkbox"; import { Button } from "../../shared/design-system/ui/Button"; +import { historyFailure, UPDATES_LINK } from "./history-failure"; +import { windowFailure, type WindowFailure } from "../relay/window-failure"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { useEffect, @@ -85,7 +87,7 @@ export function MediaReviewViewer(props: MediaReviewViewerProps) { request: 0, })); const [view, setView] = useState(); - const [threadError, setThreadError] = useState(); + const [threadError, setThreadError] = useState(); useEffect(() => { try { const owned = session.thread(channelId, messageId, { exact: true }); @@ -94,7 +96,7 @@ export function MediaReviewViewer(props: MediaReviewViewerProps) { void owned.refresh(); return () => owned.dispose(); } catch (error) { - setThreadError(String(error)); + setThreadError(windowFailure(error)); } }, [session, channelId, messageId]); const activeProps = { @@ -142,7 +144,7 @@ function ResolvedReview({ ...props }: ActiveReviewProps & { view: ThreadView | undefined; - threadError: string | undefined; + threadError: WindowFailure | undefined; }) { const snapshot = useSyncExternalStore( view?.subscribe ?? noThreadSubscription, @@ -181,28 +183,34 @@ function ResolvedReview({ ? knownRows.flatMap((row) => row.attachments) : props.initialAttachments; const pendingProps = { ...props, galleryAttachments }; - if (threadError) return ; + if (threadError) + return ( + + ); if (!view || !snapshot) return ; if (invalidated || (blockedView === view && !authoritative)) return ( ); if (snapshot.error) return ( ); if (snapshot.status === "loading" || snapshot.status === "idle") @@ -239,6 +247,20 @@ function ResolvedReview({ ); } +/** The shell's error, retry and update for a failed thread read. */ +function failureProps( + error: unknown, + onOpenLink: (url: string) => boolean, + refresh?: () => void | Promise, +) { + const failure = historyFailure(error, "This attachment didn’t load."); + return { + error: failure.text, + ...(failure.retry && refresh ? { retry: refresh } : {}), + ...(failure.update ? { update: () => void onOpenLink(UPDATES_LINK) } : {}), + }; +} + function ReviewShell({ attachment, galleryAttachments = [], @@ -262,6 +284,7 @@ function ReviewShell({ unavailable = false, error, retry, + update, restoreFocus, selectAttachment, selectionRequest, @@ -276,6 +299,8 @@ function ReviewShell({ unavailable?: boolean; error?: string; retry?: () => void | Promise; + /** Offered when this Buzz can't use the answer and a retry fails again. */ + update?: () => void; }) { const source = session.media(attachment.url); // Listener unavailability is not a playback error the selection resets. @@ -288,6 +313,13 @@ function ReviewShell({ frame.current?.focus({ preventScroll: true }); void retry?.(); }; + // Like Try again, shown once: beside the media when it is unavailable, + // otherwise beside the comments. + const updateButton = update && ( + + ); const video = useRef(null); const [commentsChoice, setCommentsOpen] = useState(); const [selectedImageUrl, setSelectedImageUrl] = useState(attachment.url); @@ -547,9 +579,10 @@ function ReviewShell({ {error ?? "Loading comments…"} {retry && !unavailable && ( )} + {!unavailable && updateButton}

)}
@@ -568,9 +601,10 @@ function ReviewShell({ {error ?? (loading ? "Loading media…" : "Media unavailable")} {retry && unavailable && ( )} + {unavailable && updateButton}

) : attachment.kind === "video" && source ? ( false, + onOpenLink: vi.fn((_url: string) => false), ...(onOpenMediaReview ? { onOpenMediaReview } : {}), }; let mounted: ReturnType | undefined; @@ -918,7 +921,8 @@ it("keeps older-page loading quiet and retry between root and replies", () => { h.snapshot.error = "offline"; h.render(); const alert = within(section).getByRole("alert"); - expect(alert).toHaveTextContent("offline"); + expect(alert).toHaveTextContent("Older replies didn’t load."); + expect(alert).not.toHaveTextContent("offline"); expect( root.compareDocumentPosition(alert) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); @@ -934,6 +938,49 @@ it("keeps older-page loading quiet and retry between root and replies", () => { h.render(); expect(within(section).queryByText("Loading older replies…")).toBeNull(); }); +it.each([ + { + name: "a server error", + error: new ReadError("unavailable", "Relay read failed (503)", 503), + alert: "Older replies didn’t load.", + retry: true, + }, + { + name: "a paused queue", + error: new ApiPaused(3000), + alert: + "Older replies didn’t load. Buzz is busy. Try again in a few seconds.", + retry: true, + }, + { + name: "a request this Buzz can't make", + error: new ReadError("unavailable", "Relay read failed (400)", 400), + alert: "Older replies didn’t load.", + retry: false, + }, +])( + "maps a failed older-replies read for $name without raw text", + ({ error, alert, retry }) => { + const h = messagesHarness(); + h.snapshot.direction = "older"; + h.snapshot.readKind = "older"; + h.snapshot.status = "error"; + h.snapshot.error = error; + const section = h.render(); + expect(within(section).getByRole("alert")).toHaveTextContent(alert); + expect(within(section).getByRole("alert")).not.toHaveTextContent( + /relay|\b(400|503)\b/i, + ); + expect(!!within(section).queryByRole("button", { name: "Try again" })).toBe( + retry, + ); + if (retry) return; + fireEvent.click( + within(section).getByRole("button", { name: "Check for updates" }), + ); + expect(h.props.onOpenLink).toHaveBeenCalledExactlyOnceWith(UPDATES_LINK); + }, +); it("keeps retained-range repair quiet and retry after the replies", () => { const h = messagesHarness(); h.snapshot.direction = "older"; diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index d1799f15de..220da874ea 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -9,6 +9,7 @@ import { useStableRowActivity, } from "./activity-presentation"; import { activityRuns } from "./activity-groups"; +import { historyFailure, UPDATES_LINK } from "./history-failure"; import { DayDivider } from "./MessageTimestamp"; import { workflowLabel } from "../relay/workflow-attribution"; import { usePanelTabHost } from "../panels/PanelWorkspace"; @@ -124,6 +125,33 @@ export type ThreadPanelProps = { // Read errors are diagnostics, not copy; the notice says what happened. const THREAD_FAILED = "This thread didn’t load."; +function OlderRepliesFailure({ + error, + onRetry, + onOpenLink, +}: { + error: unknown; + onRetry(): void; + onOpenLink(url: string): boolean; +}) { + const failure = historyFailure(error, "Older replies didn’t load."); + return ( + <> +

{failure.text}

+ {failure.retry && ( + + )} + {failure.update && ( + + )} + + ); +} + /** Safe to retarget through ordinary props; callers do not own internal remount keys. */ export function ThreadPanel(props: ThreadPanelProps) { useMessageSelectionCopy(); @@ -1925,10 +1953,11 @@ function ThreadMessages({
    {showOlderPageStatus && snapshot.error && (
  1. -

    {snapshot.error}

    - +
  2. )} {renderReplies(undefined)} diff --git a/src/features/messages/attachment-panes.test.tsx b/src/features/messages/attachment-panes.test.tsx index c04950f987..2faa3aee7a 100644 --- a/src/features/messages/attachment-panes.test.tsx +++ b/src/features/messages/attachment-panes.test.tsx @@ -308,7 +308,7 @@ it.each([false, true])( }); expect(view.container.contains(pane)).toBe(false); // Actual document.body portal. const target = unavailable - ? within(pane).getByText("Error: Unavailable review") + ? within(pane).getByText("This attachment didn’t load.") : await within(pane).findByText("Existing review comment"); expect(target.closest("form")).toBeNull(); if (!unavailable) diff --git a/src/features/messages/history-failure.test.ts b/src/features/messages/history-failure.test.ts new file mode 100644 index 0000000000..3e1b1c4c35 --- /dev/null +++ b/src/features/messages/history-failure.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from "vitest"; +import { ReadError } from "../relay/errors"; +import { ApiCapacity, ApiPaused } from "../relay/http-admission"; +import { windowFailure } from "../relay/window-failure"; +import { historyFailure } from "./history-failure"; + +const context = "Messages didn’t load."; + +it.each([ + ["a 503", new ReadError("unavailable", "Relay read failed (503)", 503)], + ["a native timeout", "network-timeout"], + ["a native outage", "network-unavailable"], + ["a cancelled read", new ReadError("cancelled", "Relay read cancelled")], + ["an unknown failure", new Error("boom")], + ["no failure", undefined], +])("keeps only the context and offers Try again for %s", (_, error) => { + expect(historyFailure(error, context)).toMatchObject({ + text: context, + retry: true, + update: false, + }); +}); + +it.each([new ApiPaused(3000), new ApiCapacity()])( + "tells the reader a busy Buzz can be tried again", + (error) => { + expect(historyFailure(error, context)).toMatchObject({ + text: `${context} Buzz is busy. Try again in a few seconds.`, + retry: true, + update: false, + }); + }, +); + +it("offers only an update when the answer can never be used", () => { + const failure = historyFailure( + new ReadError("invalid-response", "Relay response is not an event array"), + "Older messages didn’t load.", + ); + // The surface's own sentence, as for any other failure; only the button differs. + expect(failure).toMatchObject({ + text: "Older messages didn’t load.", + retry: false, + update: true, + }); + // The raw text is for diagnostics, not the surface. + expect(failure).not.toHaveProperty("details"); + expect( + historyFailure( + new ReadError("unavailable", "Relay read failed (400)", 400), + context, + ), + ).toMatchObject({ text: context, retry: false, update: true }); +}); + +it("offers neither button for a refusal and never shows the raw text", () => { + const failure = historyFailure( + new ReadError("denied", "Relay read failed (403)", 403), + context, + ); + expect(failure).toMatchObject({ + text: `${context} You don’t have access to this conversation.`, + retry: false, + update: false, + }); + expect(failure.text).not.toMatch(/relay|403/i); +}); + +it("keeps a known refusal reason instead of the fallback", () => { + expect( + historyFailure( + new ReadError("denied", "restricted: not a channel member", 403), + context, + ), + ).toMatchObject({ + text: `${context} You’re not a member of this channel.`, + retry: false, + }); +}); + +it("keeps a window failure non-empty, whatever rejected", () => { + const error = new ReadError("unavailable", "down", 503); + expect(windowFailure(error)).toBe(error); + expect(windowFailure("network-timeout")).toBe("network-timeout"); + for (const value of ["", undefined, null, 0]) + expect(windowFailure(value)).toBeInstanceOf(Error); +}); diff --git a/src/features/messages/history-failure.ts b/src/features/messages/history-failure.ts new file mode 100644 index 0000000000..68926e5e67 --- /dev/null +++ b/src/features/messages/history-failure.ts @@ -0,0 +1,40 @@ +import { failureKind, userCopy } from "../relay/user-copy"; + +/** A refused read whose reason Buzz doesn't recognize still says why. */ +const NO_ACCESS = "You don’t have access to this conversation."; + +/** Opens Settings → Software updates through a conversation's `onOpenLink`. */ +export const UPDATES_LINK = "buzz://settings?section=updates"; + +export type HistoryFailure = Readonly<{ + text: string; + /** Offer "Try again": the same read can succeed. */ + retry: boolean; + /** Offer "Check for updates": this Buzz can't use the answer, and the same + * read fails again. */ + update: boolean; +}>; + +/** What a failed history read (a `WindowFailure`, or nothing) says on the surface that shows it. `context` is + * the surface's own sentence, such as "Messages didn’t load.", and is the + * whole text for a connection failure, timeout, unusable answer or unknown + * failure. A busy Buzz or a refusal adds its reason. */ +export function historyFailure( + error: unknown, + context: string, +): HistoryFailure { + const copy = userCopy(error, context); + const kind = failureKind(error); + if (kind === "unusable") return { text: context, retry: false, update: true }; + // Refused keeps the helper's text, the context plus a known reason, and + // otherwise says the conversation isn't open to this account. + const text = + kind === "refused" + ? copy.text === context + ? `${context} ${NO_ACCESS}` + : copy.text + : kind === "busy" + ? copy.text + : context; + return { text, retry: copy.retry, update: false }; +} diff --git a/src/features/messages/use-media-read-recovery.ts b/src/features/messages/use-media-read-recovery.ts index 8acf27997f..e560e1b79d 100644 --- a/src/features/messages/use-media-read-recovery.ts +++ b/src/features/messages/use-media-read-recovery.ts @@ -1,9 +1,11 @@ import { useEffect, useRef } from "react"; +import { ReadError } from "../relay/errors"; import { retryRead } from "../relay/read-retry"; function mediaFailure(element: HTMLMediaElement): Error { + // MEDIA_ERR_NETWORK: the bytes stopped arriving, so a reload can recover. return element.error?.code === 2 - ? new TypeError("Media network read failed") + ? new ReadError("unavailable", "Media network read failed") : new Error("Media could not be decoded"); } diff --git a/src/features/messages/use-message-opening.test.ts b/src/features/messages/use-message-opening.test.ts index 3c391b21a5..fbc89a5ab0 100644 --- a/src/features/messages/use-message-opening.test.ts +++ b/src/features/messages/use-message-opening.test.ts @@ -347,7 +347,7 @@ it("waits for a queued catch-up retry instead of deciding on its earlier failure expect(channels.window("a")).toMatchObject({ status: "ready", freshness: "cached", - error: expect.any(String), + error: expect.any(ReadError), }); // The target arrived while the stream was down. const target = message(alice, "a", "target", 20); diff --git a/src/features/pages/ContributionFailure.tsx b/src/features/pages/ContributionFailure.tsx new file mode 100644 index 0000000000..5dedd4dac7 --- /dev/null +++ b/src/features/pages/ContributionFailure.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +import { ErrorDetails } from "../../shared/ErrorDetails"; + +/** The external plugin behind a failed page or panel, so the failure can name + * it and offer its management UI. Built-in contributions pass nothing. */ +export type FailedPlugin = Readonly<{ + name: string; + /** Open this plugin in Settings → Plugins. */ + manage(): void; +}>; + +/** The external plugin with this ID, or undefined for a built-in one. */ +export type PluginOwners = (pluginId: string) => FailedPlugin | undefined; + +const Owners = createContext(undefined); +/** Given by the app to the whole shell (pages, launcher companions), so a + * panel's caller can name the panel's plugin when it fails. */ +export const PluginOwnersProvider = Owners.Provider; +/** Resolves contributions' plugins; undefined without a provider, where every + * failure reads as built-in. */ +export const usePluginOwners = () => useContext(Owners); + +/** What a page or panel shows in place of itself after it threw while + * rendering. The raw exception text stays behind a collapsed "Details". */ +export function ContributionFailure({ + kind, + plugin, + details, + onRetry, +}: { + kind: "page" | "panel"; + plugin?: FailedPlugin | undefined; + details?: string | undefined; + onRetry(): void; +}) { + const Title = kind === "page" ? "h2" : "h3"; + return ( +
    + This {kind} couldn’t open +

    + {plugin + ? `${plugin.name} had a problem showing it.` + : `Something went wrong showing this ${kind}.`} +

    + {!plugin &&

    If it keeps happening, restart Buzz.

    } + {/* Secondary first, primary last, as in dialog footers and forms. */} +
    + {plugin && ( + + )} + +
    + +
    + ); +} diff --git a/src/features/pages/PageView.test.tsx b/src/features/pages/PageView.test.tsx new file mode 100644 index 0000000000..533e87af59 --- /dev/null +++ b/src/features/pages/PageView.test.tsx @@ -0,0 +1,138 @@ +// @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 { PanelCard } from "../panels/PanelCard"; +import type { RegisteredPanel } from "../panels/service"; +import { PageView } from "./PageView"; +import type { RegisteredPage } from "./service"; + +beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function pageThatThrows(state: { broken: boolean }): RegisteredPage { + return { + key: "acme.notes/home", + pluginId: "acme.notes", + revision: "r1", + id: "home", + title: "Home", + component: () => { + if (state.broken) throw new Error("Fixture page render failure"); + return

    Page content

    ; + }, + }; +} + +it("keeps a built-in page's raw failure in collapsed Details and offers Try again", () => { + const state = { broken: true }; + render(); + + expect(screen.getByRole("alert")).toBeVisible(); + expect( + screen.getByRole("heading", { name: "This page couldn’t open" }), + ).toBeVisible(); + expect( + screen.getByText("Something went wrong showing this page."), + ).toBeVisible(); + expect( + screen.getByText("If it keeps happening, restart Buzz."), + ).toBeVisible(); + expect(screen.queryByRole("button", { name: "Manage plugin" })).toBeNull(); + expect(screen.queryByText(/Open Settings to disable/)).toBeNull(); + // The exception text is only behind the collapsed disclosure. + expect(screen.getByRole("button", { name: "Details" })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.queryByText(/Fixture page render failure/)).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Details" })); + expect(screen.getByText("Error: Fixture page render failure")).toBeVisible(); + + state.broken = false; + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(screen.getByText("Page content")).toBeVisible(); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it("stays on the failure when Try again fails again", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(screen.getByRole("alert")).toBeVisible(); +}); + +it("names an external plugin and opens its management", () => { + const manage = vi.fn(); + const owners = vi.fn((pluginId: string) => + pluginId === "acme.notes" ? { name: "Acme Notes", manage } : undefined, + ); + render(); + expect(owners).toHaveBeenCalledWith("acme.notes"); + expect( + screen.getByText("Acme Notes had a problem showing it."), + ).toBeVisible(); + expect(screen.queryByText(/restart Buzz/)).toBeNull(); + expect(screen.getByRole("button", { name: "Try again" })).toBeVisible(); + // Secondary first, primary last, as in dialog footers and forms. + expect( + screen + .getAllByRole("button", { name: /^(Manage plugin|Try again)$/ }) + .map((button) => button.textContent), + ).toEqual(["Manage plugin", "Try again"]); + fireEvent.click(screen.getByRole("button", { name: "Manage plugin" })); + expect(manage).toHaveBeenCalledOnce(); +}); + +it("keeps the built-in copy for a page whose plugin isn't external", () => { + render( + undefined} + />, + ); + expect( + screen.getByText("Something went wrong showing this page."), + ).toBeVisible(); + expect(screen.queryByRole("button", { name: "Manage plugin" })).toBeNull(); +}); + +it("lets a panel inside the page name its own plugin", () => { + const manage = vi.fn(); + const panel = { + key: "other.tools/side", + pluginId: "other.tools", + revision: "r1", + id: "side", + title: "Tools", + component: () => { + throw new Error("Fixture panel render failure"); + }, + } as unknown as RegisteredPanel; + const page: RegisteredPage = { + ...pageThatThrows({ broken: false }), + component: () => ( + {}} /> + ), + }; + render( + + pluginId === "other.tools" ? { name: "Other Tools", manage } : undefined + } + />, + ); + expect( + screen.getByRole("heading", { name: "This panel couldn’t open" }), + ).toBeVisible(); + expect( + screen.getByText("Other Tools had a problem showing it."), + ).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Manage plugin" })); + expect(manage).toHaveBeenCalledOnce(); +}); diff --git a/src/features/pages/PageView.tsx b/src/features/pages/PageView.tsx index 31c9e56e67..6fc52eb020 100644 --- a/src/features/pages/PageView.tsx +++ b/src/features/pages/PageView.tsx @@ -1,21 +1,32 @@ -import { Component, useEffect, type ReactNode } from "react"; +import { Component, Fragment, useEffect, type ReactNode } from "react"; +import { rawText } from "../relay/user-copy"; +import { + ContributionFailure, + type FailedPlugin, + type PluginOwners, + PluginOwnersProvider, +} from "./ContributionFailure"; import type { PageProps, RegisteredPage } from "./service"; type BoundaryProps = { children: ReactNode; page: RegisteredPage; navigation?: PageProps["navigation"]; + plugin?: FailedPlugin | undefined; }; class PageBoundary extends Component< BoundaryProps, { - error: string | null; + failure: { details: string | undefined } | null; + /** Bumped by Try again so the page remounts with fresh state. */ + attempt: number; page: RegisteredPage; navigation: PageProps["navigation"]; } > { state = { - error: null as string | null, + failure: null as { details: string | undefined } | null, + attempt: 0, page: this.props.page, navigation: this.props.navigation, }; @@ -26,11 +37,11 @@ class PageBoundary extends Component< // A new attempt or registration can retry a failed subtree, even within one visit. // Do not remount healthy content on a reclick: its local drafts/focus still belong to it. if (props.page !== state.page || props.navigation !== state.navigation) - return { error: null, page: props.page, navigation: props.navigation }; + return { failure: null, page: props.page, navigation: props.navigation }; return null; } static getDerivedStateFromError(error: unknown) { - return { error: String(error) }; + return { failure: { details: rawText(error) } }; } componentDidCatch() { this.props.navigation?.complete({ @@ -39,41 +50,52 @@ class PageBoundary extends Component< }); } render() { - return this.state.error ? ( - + const { failure, attempt } = this.state; + return failure ? ( + + this.setState((state) => ({ + failure: null, + attempt: state.attempt + 1, + })) + } + /> ) : ( - this.props.children + {this.props.children} ); } } -function Failure({ message }: { message: string }) { - return ( -
    -

    This page couldn’t open

    -

    {message}

    -

    Open Settings to disable, delete, or roll back this plugin.

    -
    - ); -} export function PageView({ page, companion, companionOpening, navigation, -}: { page: RegisteredPage } & PageProps) { + owners, +}: { + page: RegisteredPage; + /** Names external plugins, so a failed page or panel can name its own and + * offer Manage plugin. Without it every failure reads as built-in. */ + owners?: PluginOwners | undefined; +} & PageProps) { return ( - - + - + plugin={owners?.(page.pluginId)} + key={`${page.key}:${page.revision}`} + > + + + ); } function PresentedPage({ diff --git a/src/features/pages/plugin-owners.test.ts b/src/features/pages/plugin-owners.test.ts new file mode 100644 index 0000000000..2e1348ef03 --- /dev/null +++ b/src/features/pages/plugin-owners.test.ts @@ -0,0 +1,51 @@ +import { expect, it, vi } from "vitest"; +import type { ConfigurationState, PluginInfo } from "../../plugins/types"; +import { pluginOwners } from "./plugin-owners"; + +const plugin = ( + id: string, + name: string, + source: PluginInfo["source"], +): PluginInfo => ({ + manifest: { id, name, apiVersion: 1 }, + source, + enabled: true, + revision: "r1", + previous: null, + reloadable: false, + error: null, +}); +const ready: ConfigurationState = { + status: "ready", + externalPluginsPaused: false, + catalog: { + profile: "fixture", + location: "Fixture", + plugins: [ + plugin("acme.notes", "Acme Notes", "external"), + plugin("buzz.channels", "Channels", "bundled"), + ], + }, +}; + +it("names an external plugin and opens its management in Settings", () => { + const manage = vi.fn(); + const owner = pluginOwners(ready, manage)("acme.notes"); + expect(owner?.name).toBe("Acme Notes"); + owner?.manage(); + expect(manage).toHaveBeenCalledExactlyOnceWith("acme.notes"); +}); + +it("leaves built-in and unknown plugins unnamed", () => { + const owners = pluginOwners(ready, vi.fn()); + expect(owners("buzz.channels")).toBeUndefined(); + expect(owners("missing.plugin")).toBeUndefined(); +}); + +it("names nothing until the catalog is ready", () => { + for (const configuration of [ + { status: "loading" }, + { status: "recovery", reason: "Unreadable", canReset: true }, + ] satisfies ConfigurationState[]) + expect(pluginOwners(configuration, vi.fn())("acme.notes")).toBeUndefined(); +}); diff --git a/src/features/pages/plugin-owners.ts b/src/features/pages/plugin-owners.ts new file mode 100644 index 0000000000..98d7680c59 --- /dev/null +++ b/src/features/pages/plugin-owners.ts @@ -0,0 +1,22 @@ +import type { ConfigurationState } from "../../plugins/types"; +import type { PluginOwners } from "./ContributionFailure"; + +/** Names the external plugins in the saved catalog, so a failed page or panel + * can name its own plugin and open its management UI. Built-in plugins, unknown + * IDs, and a catalog that hasn't loaded all resolve to undefined. */ +export function pluginOwners( + configuration: ConfigurationState, + manage: (pluginId: string) => void, +): PluginOwners { + return (pluginId) => { + const info = + configuration.status === "ready" + ? configuration.catalog.plugins.find( + (plugin) => plugin.manifest.id === pluginId, + ) + : undefined; + return info?.source === "external" + ? { name: info.manifest.name, manage: () => manage(pluginId) } + : undefined; + }; +} diff --git a/src/features/panels/PanelCard.tsx b/src/features/panels/PanelCard.tsx index 7b5202d6d3..7b74ec6281 100644 --- a/src/features/panels/PanelCard.tsx +++ b/src/features/panels/PanelCard.tsx @@ -10,6 +10,7 @@ import { XIcon } from "../../shared/design-system/icons/index"; import type { PanelProps, RegisteredPanel } from "./service"; import { PanelSubviewHost } from "./PanelSubview"; import { PanelView } from "./PanelView"; +import { usePluginOwners } from "../pages/ContributionFailure"; import styles from "./Panels.module.css"; // Ordinary shared UI: the caller still owns placement and selection. @@ -24,6 +25,7 @@ export function PanelCard({ contentKey?: string; }) { const tabbed = !!usePanelTabHost(); + const owners = usePluginOwners(); return (