diff --git a/docs/buzz-help.md b/docs/buzz-help.md index b57adbabfa..6f9086665c 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -15,6 +15,25 @@ Keep a first explanation brief: Solo, Team, agents, and one useful next step. Offer more detail when asked. Do not pretend to highlight controls or navigate the interface unless an available tool actually supports that action. +## Buzz Doctor + +When background reads fail, **Buzz is having issues** appears once as a toast. +Choose **Retry** to check the affected reads again, or **View issues** to open +Buzz Doctor and retry an individual source. Available local agent choices and +loaded conversations stay usable. Retrying does not resend messages or repeat +saved actions. The notice clears after the issues recover; an open Doctor dialog +stays open and shows that everything is working again. Back closes the dialog and +Forward can restore it; **Close Buzz Doctor** ends that visit. +Retry actions show a spinner and ignore repeated clicks while running. **Retry +all** checks affected background reads in sequence. The toast hides while Doctor +or Settings is open and returns on closing if issues remain. +During a retry batch, every Retry action stays disabled; only the issue currently +being retried shows a spinner. +If a retry finishes with issues remaining, the button briefly shakes unless +reduced motion is enabled. During **Retry all**, each failed issue’s Retry button +shakes as its attempt finishes, while remaining disabled until the batch ends. +Use **F6** to reach notifications by keyboard, then Tab to their actions. + ## Agents in Huddles On supported desktop hosts, a running local agent with voice support joins your diff --git a/docs/design-system-adoption.md b/docs/design-system-adoption.md index 56fe6d35ca..37580fe732 100644 --- a/docs/design-system-adoption.md +++ b/docs/design-system-adoption.md @@ -19,7 +19,7 @@ Use this map to find the shared owner before changing a product surface. | Modals | Dialog and AlertDialog | Use the [modal composition](../src/shared/design-system/DESIGN.md#modal-composition): shared title/description lockup, normalized sizes, secondary Close, and fixed footer gutter outside scrolling content. Page search, community setup, and workflow confirmations retain their dismissal and focus contracts. | | Panels and headers | Panel and PanelHeader | Settings, channels, and companion cards share appearance. Features own grids, scrolling, docks, and subscriptions. | | Read loading | LoadingRegion | Profile Settings uses a same-scope verified snapshot while refreshing; cold reads reserve the actual form geometry. Delayed status does not delay results, errors, or write feedback. | -| Feedback | Toast (Sonner) | Sidebar preferences and Settings recovery use the shared bottom-right Sonner stack with source-owned recovery. Completed-action feedback can outlive its row. Channel live-update recovery stays inline above the composer; form errors, blocked pages, and lasting paused-state context stay inline. | +| Feedback | Toast (Sonner) | Buzz Doctor consolidates background-read failures in one persistent bottom-right toast with Retry and a Dialog of individual issues/actions. Services retain retry ownership; represented sources suppress duplicate notices. Settings recovery uses source-owned notices. Completed-action feedback can outlive its row. Form errors, failed sends, blocked pages and lasting paused-state context stay inline. | | Hints | Tooltip | Control hints and agent activity support keyboard access and dismissal. Controls keep their own accessible names. | | Solo chats and activity | NavigationItem, Button, IconButton, Panel, PanelHeader | Solo chat history, agent choice, and activity actions retain unread, admission, draft, and focus behavior. Base UI owns their menus. | | Media stages | `surface-inverse` and `text-inverse` | Preserve the stage’s existing values and measure text pairings. Renderers own image and video pixels. | diff --git a/docs/navigation-history.md b/docs/navigation-history.md index 6db78744d3..d0d929ccdb 100644 --- a/docs/navigation-history.md +++ b/docs/navigation-history.md @@ -21,6 +21,11 @@ unchanged-looking stop. Opening the content explicitly again creates a fresh vis ### Dialogs +Buzz Doctor is a read-only, community-scoped global dialog using this contract. +Its visit retains the underlying workspace. Restoring it reads the current +session's issues; it never replays Retry. Recovery can show a healthy state in +the open dialog without closing it or discarding its return path. + Back closes the dialog before leaving its underlying view. Forward can reopen it. For `channel A → channel B → Members`: diff --git a/src/app/App.tsx b/src/app/App.tsx index 20476e27fc..034f2a7b46 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,6 +3,8 @@ import { AgentAvatarArtworkProvider } from "../features/agents/AgentAvatarArtwor import { Welcome } from "../features/identity/Welcome"; import { NewMenu } from "./shell/NewMenu"; import { PrimarySidebar } from "./shell/PrimarySidebar"; +import { BuzzDoctor } from "../features/relay/BuzzDoctor"; +import { conversationFocusTarget, paneFocusTarget } from "./focus-navigation"; import { ChannelNavigationProvider } from "../features/channel-navigation/ChannelNavigationState"; import { AgentsContext } from "../features/agents/react"; import { ToastProvider } from "../shared/design-system/ui/Toast"; @@ -593,7 +595,32 @@ function ConnectedAppContent({ services }: { services: AppServices }) { ); return ( - <> + + (workspace.target.kind === "conversation" + ? conversationFocusTarget(workspace.target.channelId) + : paneFocusTarget("main")) ?? + document.getElementById("main-content") ?? + undefined + } + > {isTauri() && ( - > + ); } diff --git a/src/app/navigation.test.tsx b/src/app/navigation.test.tsx index e8d7887d00..0ab67ec0c3 100644 --- a/src/app/navigation.test.tsx +++ b/src/app/navigation.test.tsx @@ -94,6 +94,48 @@ afterEach(async () => { window.history.replaceState(null, "", "/"); }); +it("keeps the underlying destination beneath Buzz Doctor and retires its visit on explicit Close", async () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + activation.release(); + const current = createServices(); + services = current; + render(); + // This fixture has no Messages plugin; its default destination settles as unavailable. + await waitFor(() => + expect(current.navigation.snapshot().status).toBe("failed"), + ); + const beneath = current.navigation.snapshot().entry.target; + void current.navigation.open({ version: 1, kind: "doctor", scope: null }); + expect( + await screen.findByRole("dialog", { name: "Buzz Doctor" }), + ).toBeVisible(); + await waitFor(() => + expect(current.navigation.snapshot().status).toBe("opened"), + ); + await act(async () => current.navigation.back()); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Buzz Doctor" })).toBeNull(), + ); + expect(current.navigation.snapshot().entry.target).toEqual(beneath); + await act(async () => current.navigation.forward()); + expect( + await screen.findByRole("dialog", { name: "Buzz Doctor" }), + ).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Close Buzz Doctor" })); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Buzz Doctor" })).toBeNull(), + ); + await act(async () => current.navigation.forward()); + expect(screen.queryByRole("dialog", { name: "Buzz Doctor" })).toBeNull(); +}); + it("waits for a starting plugin before opening its addressed Settings card", async () => { vi.stubGlobal( "ResizeObserver", diff --git a/src/app/navigation.ts b/src/app/navigation.ts index 038e7bceed..b5761818f9 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -8,7 +8,11 @@ import { } from "react"; import type { AppServices } from "./services"; import { communityDestination } from "../features/communities/destination"; -import { targetKey, type OpenTarget } from "../features/navigation/targets"; +import { + isDialogTarget, + targetKey, + type OpenTarget, +} from "../features/navigation/targets"; import type { PageNavigation } from "../features/navigation/service"; import type { OpenAttempt, @@ -58,6 +62,7 @@ export function useAppNavigation( const startup = plugins.configuration.status; const target = state.entry.target; const settings = target.kind === "settings"; + const dialog = isDialogTarget(target); const provider = settings ? target.provider : undefined; const providerAvailable = useSyncExternalStore( (listener) => @@ -89,8 +94,7 @@ export function useAppNavigation( const lastNonSettings = useRef< { target: OpenTarget; entryId: string } | undefined >(undefined); - if (target.kind !== "settings") - lastNonSettings.current = { target, entryId: state.entry.id }; + if (!dialog) lastNonSettings.current = { target, entryId: state.entry.id }; const scope = "scope" in target ? target.scope : undefined; const pageKey = target.kind === "page" @@ -261,12 +265,12 @@ export function useAppNavigation( useLayoutEffect(() => { if (owner.waiting || owner.failure) return; const previous = workspace.current; - if (!settings && previous?.presentation.resume(owner.attempt)) { + if (!dialog && previous?.presentation.resume(owner.attempt)) { setPresentation({ owner, request: previous.presentation.request }); previous.owner = owner; return; } - if (!settings) { + if (!dialog) { previous?.presentation.dispose(); workspace.current = undefined; } @@ -303,12 +307,12 @@ export function useAppNavigation( }; }, }, - !settings && !!owner.page, + !dialog && !!owner.page, ); setPresentation({ owner, request: next.request }); - if (settings) return next.dispose; + if (dialog) return next.dispose; workspace.current = { owner, presentation: next }; - }, [services, owner, settings]); + }, [services, owner, dialog]); const retained = workspace.current; const sameWorkspaceVisit = retained && @@ -321,17 +325,19 @@ export function useAppNavigation( const request = presentation?.owner === owner ? presentation.request - : !settings && sameWorkspaceVisit + : !dialog && sameWorkspaceVisit ? retainedRequest : undefined; - const workspacePage = settings - ? retainedRequest && retained?.owner.page - : page; + const workspacePage = dialog ? retainedRequest && retained?.owner.page : page; const workspaceTarget = - settings && retainedRequest && retained + dialog && retainedRequest && retained ? retained.owner.attempt.entry.target : target; - const workspaceRequest = settings ? retainedRequest : request; + const workspaceRequest = dialog ? retainedRequest : request; + useEffect(() => { + if (target.kind === "doctor" && !waiting && !failure) + request?.complete({ status: "opened" }); + }, [target, waiting, failure, request]); useEffect(() => { if (state.attempt.signal.aborted) return; if (failure) { @@ -453,9 +459,9 @@ export function useAppNavigation( target: workspaceTarget, request: workspaceRequest, selected: - workspacePage?.key ?? (settings ? "home" : (pageKey ?? target.kind)), + workspacePage?.key ?? (dialog ? "home" : (pageKey ?? target.kind)), attempt: - settings && retainedRequest && retained + dialog && retainedRequest && retained ? retained.owner.attempt.id : state.attempt.id, }, @@ -475,6 +481,15 @@ export function useAppNavigation( }, settings); } }, + leaveDoctor() { + const current = navigation.snapshot().entry.target; + if (current.kind !== "doctor") return; + const key = targetKey(current); + navigation.dismiss( + (target) => targetKey(target) === key, + lastNonSettings.current?.target ?? { version: 1, kind: "home" }, + ); + }, dismissPluginImport(request: PageNavigation | undefined) { const current = request?.target; if (!request || current?.kind !== "settings" || !current.pluginImport) diff --git a/src/app/shell/PrimarySidebar.tsx b/src/app/shell/PrimarySidebar.tsx index 12ae14269d..1cd158fa32 100644 --- a/src/app/shell/PrimarySidebar.tsx +++ b/src/app/shell/PrimarySidebar.tsx @@ -9,7 +9,6 @@ import { import { SoloRegions } from "../../features/channel-navigation/SoloRegions"; import { SoloInventory } from "../../features/channel-navigation/SoloInventory"; import { soloDraft } from "../../features/channel-navigation/routes"; -import { RecoveryNotice } from "../../features/relay/RecoveryNotice"; import { ChannelInventory, sidebarEntryLocation, @@ -276,11 +275,6 @@ function ScopedSidebar({ panelId={panelId} {...frame} > - {/* Relay outages for every inventory recover through one - automatic owner; its notice is the single Retry. */} - {!connection.cached && ( - - )} void) | undefined; }) { + const doctor = useDoctorIssue(live); + const hosted = useDoctorHosted(); const { recovering: retrying, shown } = useInlineRecovery( - diagnostics ? undefined : recovery, + diagnostics || hosted ? undefined : recovery, ); const focused = useRef(false); const snapshot = useSyncExternalStore( @@ -180,6 +186,7 @@ export function LiveStatus({ > ); } + if (doctor) return null; if (recovery && shown) return ( )} - {snapshot.status === "error" && ( + {snapshot.status === "error" && !doctor && ( {snapshot.error ?? "Workspaces couldn’t load."} void preferences.refresh()}> diff --git a/src/features/channel-navigation/ChannelInventory.tsx b/src/features/channel-navigation/ChannelInventory.tsx index 14302dd469..89eac54edf 100644 --- a/src/features/channel-navigation/ChannelInventory.tsx +++ b/src/features/channel-navigation/ChannelInventory.tsx @@ -1,4 +1,5 @@ import { revealCreatedChannel } from "./created-channel"; +import { useDoctorIssue } from "../relay/BuzzDoctor"; import { useCallback, useEffect, @@ -197,6 +198,10 @@ export function ChannelInventory({ rosterRecovering(live.roster), ); const preferences = useSidebarPreferences(queries.sidebarPreferences); + const doctorCatalog = useDoctorIssue(queries.channelKit); + const doctorVisibility = useDoctorIssue(queries.channelLifecycle); + const doctorSidebar = useDoctorIssue(queries.sidebarPreferences); + const doctorLive = useDoctorIssue(queries.live); const sortModesReady = pluginsSettled || Object.values(preferences.data?.sort ?? {}).every( @@ -1247,16 +1252,16 @@ export function ChannelInventory({ }, before: ( <> - {/* The sidebar mounts the one automatic relay-recovery notice. - Other failures need the user and keep their own message and Retry. */} - {kitState.status === "error" && !kitState.transient && ( - - {kitState.error}{" "} - void queries.channelKit.refresh()}> - Retry templates - - - )} + {kitState.status === "error" && + !kitState.transient && + !doctorCatalog && ( + + {kitState.error}{" "} + void queries.channelKit.refresh()}> + Retry templates + + + )} {setupNotices.map((notice) => ( ))} - {!cached && dmVisibility.status === "error" && ( + {!cached && dmVisibility.status === "error" && !doctorVisibility && ( Hidden conversations could not be refreshed.{" "} )} - {list.status === "error" && !rosterRecovers && ( + {list.status === "error" && !rosterRecovers && !doctorLive && ( {list.error} @@ -1791,7 +1796,8 @@ export function ChannelInventory({ {!cached && startup.ready && preferences.status === "error" && - !preferences.transient ? ( + !preferences.transient && + !doctorSidebar ? ( rosterRecovering(live.roster), ); + const doctor = useDoctorIssue(session.live); const selected = target.kind === "conversation" ? target.channelId : undefined; const draft = @@ -358,7 +360,7 @@ export function SoloInventory({ renderChat={renderChat} /> {list.status === "loading" && Loading conversations…} - {list.status === "error" && !rosterRecovers && ( + {list.status === "error" && !rosterRecovers && !doctor && ( {list.error} )} > diff --git a/src/features/navigation/controller.ts b/src/features/navigation/controller.ts index 9f6494e677..deb0c2e8aa 100644 --- a/src/features/navigation/controller.ts +++ b/src/features/navigation/controller.ts @@ -1,4 +1,9 @@ -import { parseOpenTarget, targetKey, type OpenTarget } from "./targets"; +import { + isDialogTarget, + parseOpenTarget, + targetKey, + type OpenTarget, +} from "./targets"; import type { DismissedVisits, NavigationEntry, @@ -459,8 +464,7 @@ export function createNavigationController( // that visit, so it never replaces it. Any forward entries stay. const dismiss = options?.dismiss || - (target.kind !== "settings" && - passes(history.snapshot().current.id)); + (!isDialogTarget(target) && passes(history.snapshot().current.id)); const previous = history.snapshot().previous; if ( dismiss && diff --git a/src/features/navigation/service.ts b/src/features/navigation/service.ts index ffc879cc96..7ffebeaf0d 100644 --- a/src/features/navigation/service.ts +++ b/src/features/navigation/service.ts @@ -9,7 +9,12 @@ import { } from "./controller"; import { createBrowserHistory } from "./browser-history"; import { createMemoryHistory } from "./history"; -import { targetKey, type JsonValue, type OpenTarget } from "./targets"; +import { + isDialogTarget, + targetKey, + type JsonValue, + type OpenTarget, +} from "./targets"; import type { RelayData, RelaySnapshot } from "../relay/service"; export type PageNavigation = Readonly<{ @@ -114,7 +119,7 @@ export function provideNavigation( resolve: (target) => { if ( retainUnderSettings && - controller.navigation.snapshot().entry.target.kind === "settings" + isDialogTarget(controller.navigation.snapshot().entry.target) ) { resolution = target; return false; @@ -123,7 +128,7 @@ export function provideNavigation( }, complete: (result) => { const state = controller.navigation.snapshot(); - if (retainUnderSettings && state.entry.target.kind === "settings") { + if (retainUnderSettings && isDialogTarget(state.entry.target)) { completion = result; return false; } @@ -140,7 +145,7 @@ export function provideNavigation( if ( retainUnderSettings && !state.ingress && - state.entry.target.kind === "settings" + isDialogTarget(state.entry.target) ) { suspended = true; return true; diff --git a/src/features/navigation/targets.test.ts b/src/features/navigation/targets.test.ts index dce9ccdc6f..b925a542a0 100644 --- a/src/features/navigation/targets.test.ts +++ b/src/features/navigation/targets.test.ts @@ -15,6 +15,32 @@ const conversation = { } as const; describe("open target boundary", () => { + it("bounds Buzz Doctor to presentation and optional community scope", () => { + expect( + parseOpenTarget({ + version: 1, + kind: "doctor", + scope: conversation.scope, + }), + ).toEqual({ + version: 1, + kind: "doctor", + scope: { viewer, communityOrigin: "https://relay.example" }, + }); + expect( + parseOpenTarget({ version: 1, kind: "doctor", scope: null }), + ).toEqual({ version: 1, kind: "doctor", scope: null }); + for (const extra of [ + { retry: true }, + { issue: "secret" }, + { + scope: { viewer: "invalid", communityOrigin: "https://relay.example" }, + }, + ]) + expect(() => + parseOpenTarget({ version: 1, kind: "doctor", ...extra }), + ).toThrow(); + }); it("bounds a scoped forwarding presentation independently of its underlying message", () => { const target = parseOpenTarget({ ...conversation, diff --git a/src/features/navigation/targets.ts b/src/features/navigation/targets.ts index bf130ec529..601a838021 100644 --- a/src/features/navigation/targets.ts +++ b/src/features/navigation/targets.ts @@ -26,6 +26,7 @@ export type UrlPluginSource = Readonly<{ export type PluginLinkSource = GitPluginSource | UrlPluginSource; export type OpenTarget = | Readonly<{ version: 1; kind: "home" }> + | Readonly<{ version: 1; kind: "doctor"; scope?: NavigationScope | null }> | Readonly<{ version: 1; kind: "settings"; @@ -279,6 +280,16 @@ export function parseOpenTarget(input: unknown): OpenTarget { fields(value, ["version", "kind"]); target = { version: 1, kind: "home" }; break; + case "doctor": + fields(value, ["version", "kind", "scope"]); + target = { + version: 1, + kind: "doctor", + ...(value.scope !== undefined + ? { scope: value.scope === null ? null : boundScope(value.scope) } + : {}), + }; + break; case "settings": fields(value, [ "version", @@ -447,3 +458,7 @@ export function parseOpenTarget(input: unknown): OpenTarget { export function targetKey(target: OpenTarget): string { return JSON.stringify(parseOpenTarget(target)); } + +/** Global dialogs retain the workspace beneath their navigation visit. */ +export const isDialogTarget = (target: OpenTarget) => + target.kind === "settings" || target.kind === "doctor"; diff --git a/src/features/relay/BuzzDoctor.test.tsx b/src/features/relay/BuzzDoctor.test.tsx new file mode 100644 index 0000000000..b9f33b5bb1 --- /dev/null +++ b/src/features/relay/BuzzDoctor.test.tsx @@ -0,0 +1,321 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { useSyncExternalStore } from "react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + within, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { BuzzDoctor, useDoctorIssue } from "./BuzzDoctor"; +import { doctorFixture, deferred } from "./doctor-testing"; +import { ReadError } from "./errors"; +import { ToastProvider } from "../../shared/design-system/ui/Toast"; +import { createNavigationController } from "../navigation/controller"; +import { createMemoryHistory } from "../navigation/history"; +import { LiveStatus } from "../../bundled/channels/LiveStatus"; + +const fixtures: ReturnType[] = []; +const controllers: ReturnType[] = []; +beforeEach(() => { + vi.stubGlobal("matchMedia", (media: string) => ({ + media, + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); +}); +afterEach(() => { + cleanup(); + for (const fixture of fixtures.splice(0)) fixture.owner.dispose(); + for (const controller of controllers.splice(0)) controller.dispose(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +it("keeps a catalog-only recovery out of LiveStatus while Doctor hosts its notice", async () => { + vi.useFakeTimers(); + const h = doctorFixture({ kit: true }); + fixtures.push(h); + h.failures.set(30078, new ReadError("unavailable", "Catalog offline", 503)); + await h.session.channelKit.refresh(); + await vi.advanceTimersByTimeAsync(3_000); + expect(h.session.recovery?.snapshot().shown).toBe(true); + const controller = createNavigationController(createMemoryHistory()); + controllers.push(controller); + render( + + {}} + > + + + , + ); + expect( + screen.queryByRole("status", { name: "Reconnecting to relay…" }), + ).toBeNull(); +}); +function LocalNotice({ source, label }: { source: object; label: string }) { + return useDoctorIssue(source) ? null : {label}; +} + +it("returns lost focus to the current workspace when a focused toast action recovers", async () => { + const h = doctorFixture(); + fixtures.push(h); + h.failures.set(30622, new ReadError("denied", "Visibility refused", 403)); + await h.session.channelLifecycle.refreshVisibility(); + const controller = createNavigationController(createMemoryHistory()); + controllers.push(controller); + render( + + {}} + focusTarget={() => + document.getElementById("doctor-return") ?? undefined + } + > + + + , + ); + const toast = await screen.findByRole("dialog", { + name: "Buzz is having issues", + }); + const retry = within(toast).getByRole("button", { + name: "Retry", + }); + retry.focus(); + h.failures.clear(); + fireEvent.click(retry); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Buzz is having issues" }), + ).toBeNull(), + ); + await waitFor(() => + expect(screen.getByRole("textbox", { name: "Draft" })).toHaveFocus(), + ); +}); + +it.each(["toast", "row"] as const)( + "blocks repeated actions while retrying from %s", + async (source) => { + const h = doctorFixture(); + fixtures.push(h); + h.failures.set(30622, new ReadError("denied", "Visibility refused", 403)); + await h.session.channelLifecycle.refreshVisibility(); + const reading = deferred(); + const result = deferred<[]>(); + h.query.mockImplementation(async () => { + reading.resolve(); + return result.promise; + }); + const controller = createNavigationController(createMemoryHistory()); + controllers.push(controller); + const props = { + relay: h.relay, + navigation: controller.navigation, + onClose() {}, + }; + const { rerender } = render( + + + {null} + + , + ); + const retry = + source === "toast" + ? within( + await screen.findByRole("dialog", { + name: "Buzz is having issues", + }), + ).getByRole("button", { name: "Retry" }) + : screen.getByRole("button", { name: "Retry hidden conversations" }); + fireEvent.click(retry); + await reading.promise; + try { + expect(retry).toHaveAttribute("aria-busy", "true"); + expect(retry).toHaveAttribute("data-loading", "true"); + expect(retry).toBeDisabled(); + fireEvent.click(retry); + rerender( + + + {null} + + , + ); + const dialog = await screen.findByRole("dialog", { name: "Buzz Doctor" }); + const row = within(dialog).getByRole("button", { + name: "Retry hidden conversations", + }); + const all = within(dialog).getByRole("button", { + name: "Retry all", + }); + for (const control of [row, all]) { + expect(control).toBeDisabled(); + fireEvent.click(control); + } + expect(row).toHaveAttribute("aria-busy", "true"); + if (source === "toast") expect(all).toHaveAttribute("aria-busy", "true"); + else expect(all).not.toHaveAttribute("aria-busy", "true"); + expect(h.query).toHaveBeenCalledTimes(2); + } finally { + await act(async () => { + result.resolve([]); + }); + } + await waitFor(() => + expect( + screen.getByRole("dialog", { name: "Buzz Doctor" }), + ).toHaveTextContent("Everything is working again"), + ); + }, +); + +it("shakes failed rows after Retry all, repeats direct feedback, and respects reduced motion", async () => { + const h = doctorFixture(); + fixtures.push(h); + h.failures.set(30622, new ReadError("denied", "Visibility refused", 403)); + await h.session.channelLifecycle.refreshVisibility(); + const controller = createNavigationController(createMemoryHistory()); + controllers.push(controller); + render( + {}} + > + {null} + , + ); + const row = screen.getByRole("button", { + name: "Retry hidden conversations", + }); + const all = screen.getByRole("button", { name: "Retry all" }); + const cancel = vi.fn(); + const rowAnimation = vi.fn(() => ({ cancel }) as unknown as Animation); + const allAnimation = vi.fn(() => ({ cancel }) as unknown as Animation); + row.animate = rowAnimation; + all.animate = allAnimation; + fireEvent.click(row); + await waitFor(() => expect(rowAnimation).toHaveBeenCalledTimes(1)); + expect(allAnimation).not.toHaveBeenCalled(); + fireEvent.click(row); + await waitFor(() => expect(rowAnimation).toHaveBeenCalledTimes(2)); + fireEvent.click(all); + await waitFor(() => expect(allAnimation).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(rowAnimation).toHaveBeenCalledTimes(3)); + vi.stubGlobal("matchMedia", () => ({ matches: true })); + const reads = h.query.mock.calls.length; + fireEvent.click(row); + await waitFor(() => expect(h.query).toHaveBeenCalledTimes(reads + 1)); + await waitFor(() => expect(row).toBeEnabled()); + expect(rowAnimation).toHaveBeenCalledTimes(3); + h.failures.clear(); + vi.stubGlobal("matchMedia", () => ({ matches: false })); + fireEvent.click(row); + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Retry hidden conversations" }), + ).toBeNull(), + ); + expect(rowAnimation).toHaveBeenCalledTimes(3); +}); + +it("offers one toast, opens individual actions, preserves unrelated local feedback and stays open after recovery", async () => { + const h = doctorFixture(), + other = doctorFixture(); + fixtures.push(h, other); + h.failures.set( + 30622, + new ReadError("denied", "Visibility needs permission", 403), + ); + await h.session.channelLifecycle.refreshVisibility(); + const controller = createNavigationController(createMemoryHistory()); + controllers.push(controller); + const navigation = controller.navigation; + function Host() { + const state = useSyncExternalStore( + navigation.subscribe, + navigation.snapshot, + ); + return ( + + + navigation.dismiss([state.entry.id], { version: 1, kind: "home" }) + } + > + + + + + ); + } + render(); + const toast = await screen.findByRole("dialog", { + name: "Buzz is having issues", + }); + expect(screen.queryByText("Duplicate visibility error")).toBeNull(); + expect(screen.getByText("Unrelated source error")).toBeVisible(); + fireEvent.click(within(toast).getByRole("button", { name: "View issues" })); + const dialog = await screen.findByRole("dialog", { name: "Buzz Doctor" }); + expect(dialog).toHaveAttribute("data-navigation-visit"); + expect(within(dialog).getByText("Visibility needs permission")).toBeVisible(); + expect( + within(dialog).getByRole("button", { name: "Retry hidden conversations" }), + ).toBeVisible(); + await act(async () => navigation.back()); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Buzz Doctor" })).toBeNull(), + ); + await act(async () => navigation.forward()); + expect( + await screen.findByRole("dialog", { name: "Buzz Doctor" }), + ).toBeVisible(); + h.failures.clear(); + fireEvent.click( + screen.getByRole("button", { name: "Retry hidden conversations" }), + ); + expect( + await screen.findByText( + "Everything is working again. No issues need attention.", + ), + ).toBeVisible(); + expect(screen.getByRole("dialog", { name: "Buzz Doctor" })).toBeVisible(); + expect(screen.queryByRole("button", { name: "Retry all" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Close Buzz Doctor" })); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Buzz Doctor" })).toBeNull(), + ); + await act(async () => navigation.forward()); + expect(screen.queryByRole("dialog", { name: "Buzz Doctor" })).toBeNull(); +}); diff --git a/src/features/relay/BuzzDoctor.tsx b/src/features/relay/BuzzDoctor.tsx new file mode 100644 index 0000000000..dd28558c74 --- /dev/null +++ b/src/features/relay/BuzzDoctor.tsx @@ -0,0 +1,242 @@ +import { + createContext, + useContext, + useMemo, + useEffect, + useRef, + useSyncExternalStore, + type ReactNode, + type RefObject, +} from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { PreferenceRow } from "../../shared/design-system/ui/PreferenceRow"; +import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; +import { ToastNotice } from "../../shared/design-system/ui/Toast"; +import type { Navigation } from "../navigation/controller"; +import type { NavigationScope } from "../navigation/targets"; +import { createDoctor, type DoctorIssueId } from "./doctor"; +import type { RelayData } from "./service"; +import { DoctorRetryButton } from "./DoctorRetryButton"; + +const Context = createContext | null>(null); +export function useDoctorHosted() { + return useContext(Context) !== null; +} + +/** Hide a source's duplicate notice only when Doctor represents that issue. */ +export function useDoctorIssue(source: object) { + return useContext(Context)?.has(source) ?? false; +} + +const retryLabels: Record = { + connection: "Retry connection", + live: "Retry live updates", + catalog: "Retry templates", + workspaces: "Retry workspaces", + sidebar: "Retry sidebar preferences", + visibility: "Retry hidden conversations", + agents: "Retry agents", +}; + +function DoctorActions({ + retry, + show, + busy, + viewButton, + focusTarget, +}: { + retry(): Promise; + show(): void; + busy: boolean; + viewButton: RefObject; + focusTarget: (() => HTMLElement | undefined) | undefined; +}) { + const focused = useRef(false); + const target = useRef(focusTarget); + target.current = focusTarget; + useEffect( + () => () => { + if (!focused.current) return; + // The portal clears after its source unmounts. Restore only lost focus; + // another control or an open dialog keeps the person's current focus. + queueMicrotask(() => { + if (document.activeElement === document.body) + target.current?.()?.focus({ preventScroll: true }); + }); + }, + [], + ); + return ( + { + focused.current = true; + }} + onBlurCapture={(event) => { + if (event.relatedTarget) + focused.current = event.currentTarget.contains(event.relatedTarget); + }} + > + + Retry + + + View issues + + + ); +} + +export function BuzzDoctor({ + relay, + navigation, + open, + onClose, + children, + focusTarget, + scope, +}: { + relay: RelayData; + navigation: Navigation; + open: boolean; + onClose(): void; + children: ReactNode; + focusTarget?: (() => HTMLElement | undefined) | undefined; + scope?: NavigationScope | null; +}) { + const viewer = scope?.viewer, + origin = scope?.communityOrigin; + const destination = useSyncExternalStore( + navigation.subscribe, + navigation.snapshot, + ); + const doctor = useMemo( + () => createDoctor(relay, viewer, origin), + [relay, viewer, origin], + ); + const viewButton = useRef(null); + const state = useSyncExternalStore( + doctor.subscribe, + doctor.snapshot, + doctor.snapshot, + ); + const context = useMemo(() => { + const session = state.session; + const owners: Record = { + connection: relay, + live: session.live, + catalog: session.channelKit, + workspaces: session.soloPreferences, + sidebar: session.sidebarPreferences, + visibility: session.channelLifecycle, + agents: session.agentChoices, + }; + return new Set(state.issues.map((issue) => owners[issue.id])); + }, [state, relay]); + const show = () => { + void navigation.open({ + version: 1, + kind: "doctor", + scope: scope ?? null, + }); + }; + const retry = async (issue?: (typeof state.issues)[number]) => { + const before = doctor.snapshot(); + if (before.busy) return false; + const selected = issue ? [issue.id] : before.issues.map((item) => item.id); + if (!issue) await doctor.retry(); + else await doctor.retry(issue.id); + const after = doctor.snapshot(); + const remaining = + before.session === after.session + ? after.issues.filter( + (item) => selected.includes(item.id) && !item.busy, + ) + : []; + return remaining.length > 0; + }; + return ( + + {children} + {!open && + destination.entry.target.kind !== "settings" && + state.issues.some((issue) => !issue.quiet) && ( + + retry()} + show={show} + busy={state.busy} + viewButton={viewButton} + focusTarget={focusTarget} + /> + + )} + { + if (!next) onClose(); + }} + title="Buzz Doctor" + height="stable" + finalFocus={() => viewButton.current ?? focusTarget?.() ?? true} + visit + closeLabel="Close Buzz Doctor" + description={ + state.issues.length + ? "Review issues and retry affected reads." + : state.checking + ? "Checking your community connection…" + : "Everything is working again. No issues need attention." + } + actions={ + state.issues.length ? ( + retry()} + > + Retry all + + ) : undefined + } + > + {state.issues.length > 0 && ( + + + {state.issues.map((issue) => ( + retry(issue)} + > + Retry + + } + /> + ))} + + + )} + + + ); +} diff --git a/src/features/relay/DoctorRetryButton.tsx b/src/features/relay/DoctorRetryButton.tsx new file mode 100644 index 0000000000..fd47fb53cf --- /dev/null +++ b/src/features/relay/DoctorRetryButton.tsx @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useRef } from "react"; +import { Button, type ButtonProps } from "../../shared/design-system/ui/Button"; + +/** Feedback belongs to the action that was pressed, after its read settles. */ +export function DoctorRetryButton({ + retry, + failure, + ...props +}: Pick< + ButtonProps, + "children" | "disabled" | "loading" | "aria-label" | "size" +> & { retry(): Promise; failure?: number | undefined }) { + const button = useRef(null); + const animation = useRef(undefined); + useEffect(() => () => animation.current?.cancel(), []); + const failed = useCallback(() => { + const control = button.current; + if ( + !control || + typeof control.animate !== "function" || + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) + return; + const style = getComputedStyle(control); + animation.current?.cancel(); + animation.current = control.animate( + [ + "0", + "calc(-1 * var(--space-1))", + "var(--space-1)", + "calc(-1 * var(--space-half))", + "var(--space-half)", + "0", + ].map((x) => ({ transform: `translateX(${x})` })), + { + duration: + Number.parseFloat(style.getPropertyValue("--duration-settle")) || 220, + easing: style.getPropertyValue("--easing-state").trim() || "ease-out", + }, + ); + }, []); + const previous = useRef(failure); + useEffect(() => { + if (failure && failure !== previous.current) failed(); + previous.current = failure; + }, [failure, failed]); + return ( + { + animation.current?.cancel(); + void retry().then((remains) => { + if (remains && failure === undefined) failed(); + }, failed); + }} + /> + ); +} diff --git a/src/features/relay/broker-live.test.ts b/src/features/relay/broker-live.test.ts index fc9a2d5bdf..1270fe4bf7 100644 --- a/src/features/relay/broker-live.test.ts +++ b/src/features/relay/broker-live.test.ts @@ -177,6 +177,48 @@ it("pre-header clicks preserve in-progress POST; duplicate controls coalesce", a owner.dispose(); } }); +it.each([false, true])( + "successful retry clears its old control error without erasing a newer failure: %s", + async (newerFailure) => { + vi.useFakeTimers(); + const f = fixture(); + const transport = await connectBrokerTransport(); + const owner = required(transport.subscribe)(f.callbacks); + try { + f.accept(0); + await tick(); + f.publish(0); + await tick(); + owner.retry(); + required(f.controls[0]).resolve(new Response(null, { status: 503 })); + await tick(); + expect(f.snapshots.at(-1)).toMatchObject({ + error: "Live retry control failed (503)", + }); + if (newerFailure) { + f.publish(0, { + status: "connected", + routes: [], + error: "restricted: account needs attention", + }); + await tick(); + } + owner.retry(); + required(f.controls[1]).resolve(new Response(null, { status: 200 })); + await tick(); + expect(f.snapshots.at(-1)).toEqual({ + status: "connected", + routes: [], + ...(newerFailure + ? { error: "restricted: account needs attention" } + : {}), + }); + } finally { + owner.dispose(); + } + }, +); + for (const finish of ["replacement", "dispose"] as const) for (const outcome of ["status", "reject"] as const) it(`late retry ${outcome} cannot update ${finish}`, async () => { diff --git a/src/features/relay/broker-live.ts b/src/features/relay/broker-live.ts index e021aeb285..32142090e5 100644 --- a/src/features/relay/broker-live.ts +++ b/src/features/relay/broker-live.ts @@ -64,6 +64,7 @@ export function subscribeBrokerTraffic( let receiving = false; const routeState = new Map(); let latest: LiveSnapshot = { status: "connecting", routes: [] }; + let retryControlError: string | undefined; const publish = (snapshot: LiveSnapshot) => { latest = snapshot; callbacks.state(snapshot); @@ -731,18 +732,24 @@ export function subscribeBrokerTraffic( ]), }) .then((response) => { - if (!closed && current === generation && !response.ok) + if (closed || current !== generation) return; + if (!response.ok) throw new Error(`Live retry control failed (${response.status})`); + if (retryControlError && latest.error === retryControlError) { + const { error: _error, ...recovered } = latest; + publish(recovered); + } + retryControlError = undefined; }) .catch((error) => { - if (!closed && current === generation) + if (!closed && current === generation) { + retryControlError = + error instanceof Error ? error.message : "Live retry unavailable"; publish({ ...latest, - error: - error instanceof Error - ? error.message - : "Live retry unavailable", + error: retryControlError, }); + } }) .finally(() => { if (current === generation) controlPending = false; diff --git a/src/features/relay/doctor-testing.ts b/src/features/relay/doctor-testing.ts new file mode 100644 index 0000000000..6fb95d40ed --- /dev/null +++ b/src/features/relay/doctor-testing.ts @@ -0,0 +1,101 @@ +import { vi } from "vitest"; +import { createRelaySession } from "./session"; +import { keypair } from "./testing"; +import type { ReadFilter, RelayEvent } from "./events"; +import type { RelayData, RelaySnapshot } from "./service"; +import type { LiveCallbacks } from "./live"; + +export function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +export function doctorFixture({ kit = false } = {}) { + const viewer = keypair(), + relayAuthor = keypair(), + agent = keypair(); + const failures = new Map(); + const query = vi.fn( + async (filters: readonly ReadFilter[], _signal?: AbortSignal) => { + for (const filter of filters) + for (const kind of filter.kinds ?? []) + if (failures.has(kind)) throw failures.get(kind); + return [] as RelayEvent[]; + }, + ); + const sign = vi.fn(async () => { + throw new Error("Doctor must never sign"); + }); + const publish = vi.fn(async () => { + throw new Error("Doctor must never publish"); + }); + let live!: LiveCallbacks; + const owner = createRelaySession({ + viewer: viewer.pubkey, + relayAuthor: relayAuthor.pubkey, + query, + media: () => undefined, + subscribe: (callbacks) => { + live = callbacks; + return { update() {}, retry() {}, dispose() {} }; + }, + readAgentLibrary: async () => ({ + definitions: [], + identities: [{ pubkey: agent.pubkey, name: "Saved agent" }], + }), + decodeSidebarPreferences: async () => ({ + sections: [], + assignments: {}, + starred: [], + muted: [], + }), + ...(kit + ? { + writer: { kinds: [30078], sign, publish }, + channelKit: { prepare: sign, decode: async () => [] }, + } + : {}), + }); + live.state({ status: "connected", routes: [] }); + let snapshot: RelaySnapshot = { + session: owner.session, + status: "ready", + generation: 0, + viewer: viewer.pubkey, + origin: "https://relay.test", + scope: `https://relay.test:${viewer.pubkey}`, + }; + const listeners = new Set<() => void>(); + const relay: RelayData = { + snapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + retry: vi.fn(), + disconnect: vi.fn(), + clearCache: vi.fn(async () => {}), + }; + return { + owner, + session: owner.session, + relay, + failures, + query, + sign, + publish, + setConnection(next: Partial) { + snapshot = { ...snapshot, ...next }; + for (const listener of listeners) listener(); + }, + count: (kind: number) => + query.mock.calls.filter(([filters]) => + filters.some((filter) => filter.kinds?.includes(kind)), + ).length, + }; +} diff --git a/src/features/relay/doctor.test.ts b/src/features/relay/doctor.test.ts new file mode 100644 index 0000000000..e866cfd172 --- /dev/null +++ b/src/features/relay/doctor.test.ts @@ -0,0 +1,348 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createDoctor } from "./doctor"; +import { doctorFixture, deferred } from "./doctor-testing"; +import { ReadError } from "./errors"; + +const fixtures: ReturnType[] = []; +const setup = (options?: Parameters[0]) => { + const fixture = doctorFixture(options); + fixtures.push(fixture); + return { ...fixture, doctor: createDoctor(fixture.relay) }; +}; +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0.5); +}); +afterEach(() => { + for (const fixture of fixtures.splice(0)) fixture.owner.dispose(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); +const offline = () => + new ReadError("unavailable", "Community unavailable", 503); + +it("does not start idle inventory or preference reads when retrying an unrelated issue", async () => { + const h = setup(); + h.failures.set(30622, new ReadError("denied", "Visibility refused", 403)); + await h.session.channelLifecycle.refreshVisibility(); + h.failures.clear(); + await h.doctor.retry(); + expect(h.count(30175)).toBe(0); + expect(h.count(30078)).toBe(0); +}); + +it("sequences finite source retries and ignores repeated individual or combined actions during the batch", async () => { + const h = setup(); + for (const kind of [30622, 30078]) + h.failures.set(kind, new ReadError("denied", "Read refused", 403)); + await Promise.all([ + h.session.sidebarPreferences.refresh(), + h.session.channelLifecycle.refreshVisibility(), + ]); + const sidebarStarted = deferred(); + const sidebar = deferred<[]>(); + const visibilityStarted = deferred(); + const visibility = deferred<[]>(); + const order: number[] = []; + h.query.mockImplementation(async (filters) => { + const kind = filters[0]?.kinds?.[0]; + if (kind === 30078) { + order.push(kind); + sidebarStarted.resolve(); + await sidebar.promise; + throw new ReadError("denied", "Sidebar still refused", 403); + } + if (kind === 30622) { + order.push(kind); + visibilityStarted.resolve(); + return visibility.promise; + } + return []; + }); + const retry = h.doctor.retry(); + try { + await sidebarStarted.promise; + expect(h.doctor.snapshot().busy).toBe(true); + expect( + h.doctor + .snapshot() + .issues.filter((issue) => issue.busy) + .map((issue) => issue.id), + ).toEqual(["sidebar"]); + await h.doctor.retry(); + await h.doctor.retry("visibility"); + expect(order).toEqual([30078]); + sidebar.resolve([]); + await visibilityStarted.promise; + expect(order).toEqual([30078, 30622]); + expect( + h.doctor.snapshot().issues.find((issue) => issue.id === "sidebar"), + ).toMatchObject({ busy: false, failure: 1 }); + expect(h.doctor.snapshot().busy).toBe(true); + expect( + h.doctor + .snapshot() + .issues.filter((issue) => issue.busy) + .map((issue) => issue.id), + ).toEqual(["visibility"]); + } finally { + sidebar.resolve([]); + visibility.resolve([]); + await retry; + } + expect(h.doctor.snapshot().issues.map((issue) => issue.id)).toEqual([ + "sidebar", + ]); +}); + +it("keeps a connection incident while its session is replaced by a pending reconnect", () => { + const h = setup(), + next = setup(); + h.setConnection({ status: "error", error: "Connection unavailable" }); + expect(h.doctor.snapshot().issues[0]?.id).toBe("connection"); + h.setConnection({ + status: "connecting", + session: next.session, + generation: 1, + }); + expect(h.doctor.snapshot()).toMatchObject({ + checking: true, + issues: [ + expect.objectContaining({ id: "connection", busy: true, quiet: false }), + ], + }); + h.setConnection({ status: "ready" }); + expect(h.doctor.snapshot()).toMatchObject({ checking: false, issues: [] }); +}); + +it("does not expose or retry a known account/community outside the Doctor's visible scope", async () => { + const h = setup(); + const scoped = createDoctor( + h.relay, + h.relay.snapshot().viewer, + "https://other.example", + ); + h.setConnection({ status: "error", error: "Private community refusal" }); + expect(scoped.snapshot().issues).toEqual([]); + await scoped.retry(); + expect(h.relay.retry).not.toHaveBeenCalled(); +}); + +it("shows a delayed notice even when the first retry is still pending", async () => { + const h = setup(); + h.failures.set(30078, offline()); + await h.session.sidebarPreferences.refresh(); + expect(h.doctor.snapshot().issues[0]?.quiet).toBe(true); + let release!: (events: []) => void, started!: () => void; + const reading = new Promise((resolve) => { + started = resolve; + }); + h.query.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + started(); + }), + ); + const pending = h.session.sidebarPreferences.refresh(); + await reading; + try { + await vi.advanceTimersByTimeAsync(3_000); + expect(h.doctor.snapshot().issues).toEqual([ + expect.objectContaining({ id: "sidebar", busy: true, quiet: false }), + ]); + } finally { + release([]); + await pending; + } +}); + +it("makes one fresh attempt if an inventory read already pending at Retry settles with an old failure", async () => { + const h = setup(); + h.failures.set(30622, new ReadError("denied", "Visibility refused", 403)); + await h.session.channelLifecycle.refreshVisibility(); + let reject!: (error: unknown) => void, started!: () => void; + const reading = new Promise((resolve) => { + started = resolve; + }); + h.query.mockImplementationOnce( + () => + new Promise((_resolve, fail) => { + reject = fail; + started(); + }), + ); + const inventory = h.session.agentChoices.refresh(); + await reading; + h.failures.clear(); + const retry = h.doctor.retry(); + reject(new ReadError("denied", "Read started before Retry", 403)); + await Promise.all([inventory, retry]); + expect(h.session.agentChoices.snapshot().complete).toBe(true); + expect(h.doctor.snapshot().issues).toEqual([]); + expect(h.count(30175)).toBe(2); +}); + +it("one Retry covers exhausted visibility, partial inventory and sidebar reads, retaining issues while pending", async () => { + const h = setup(); + for (const kind of [30622, 30175, 30078]) h.failures.set(kind, offline()); + const reads = Promise.all([ + h.session.channelLifecycle.refreshVisibility(), + h.session.agentChoices.refresh(), + h.session.sidebarPreferences.refresh(), + ]); + await vi.advanceTimersByTimeAsync(6_000); + await reads; + expect( + h.doctor + .snapshot() + .issues.map((issue) => issue.id) + .sort(), + ).toEqual(["agents", "sidebar", "visibility"]); + expect(h.session.agentChoices.snapshot().selectable).toHaveLength(1); + h.failures.clear(); + const retry = h.doctor.retry(); + expect(h.doctor.snapshot().issues).toHaveLength(3); + expect(h.doctor.snapshot().busy).toBe(true); + expect( + h.doctor + .snapshot() + .issues.filter((issue) => issue.busy) + .map((issue) => issue.id), + ).toEqual(["sidebar"]); + await retry; + expect(h.session.channelLifecycle.snapshot().status).toBe("ready"); + expect(h.session.agentChoices.snapshot().complete).toBe(true); + expect(h.session.sidebarPreferences.snapshot().status).toBe("ready"); + expect(h.doctor.snapshot().issues).toEqual([]); + expect(h.count(30622)).toBe(5); + expect(h.count(30175)).toBe(5); + expect(h.sign).not.toHaveBeenCalled(); + expect(h.publish).not.toHaveBeenCalled(); +}); + +it("refreshes a failed Solo projection after its unchanged empty catalog recovers", async () => { + const h = setup({ kit: true }); + h.failures.set(30078, offline()); + await h.session.soloPreferences.refresh(); + expect(h.doctor.snapshot().issues.map((issue) => issue.id)).toEqual([ + "catalog", + "workspaces", + ]); + h.failures.clear(); + await h.doctor.retry(); + expect(h.session.channelKit.snapshot().status).toBe("ready"); + expect(h.session.soloPreferences.snapshot().status).toBe("ready"); + expect(h.doctor.snapshot().issues).toEqual([]); + expect(h.count(30078)).toBe(2); + expect(h.sign).not.toHaveBeenCalled(); + expect(h.publish).not.toHaveBeenCalled(); +}); + +it("keeps an unrelated denial and partial local inventory visible when other reads recover", async () => { + const h = setup(); + h.failures.set( + 30175, + new ReadError("denied", "Inventory requires permission", 403), + ); + h.failures.set(30078, offline()); + await Promise.all([ + h.session.agentChoices.refresh(), + h.session.sidebarPreferences.refresh(), + ]); + expect(h.doctor.snapshot().issues.map((issue) => issue.id)).toEqual([ + "sidebar", + "agents", + ]); + h.failures.delete(30078); + await h.doctor.retry(); + expect(h.doctor.snapshot().issues.map((issue) => issue.id)).toEqual([ + "agents", + ]); + expect(h.session.agentChoices.snapshot().selectable).toHaveLength(1); + expect(h.session.recovery?.snapshot().recovering).toBe(false); +}); + +it("retains every live rejection, including a refusal alongside transient and pending routes", () => { + const h = setup(); + const live = { + ...h.session.live, + snapshot: () => ({ + status: "connected" as const, + roster: { state: "verified" as const }, + heads: [], + routes: [ + { + id: "a", + status: "error" as const, + replay: "unknown" as const, + error: "restricted: permission required", + }, + { + id: "b", + status: "error" as const, + replay: "unknown" as const, + error: "Temporary failure", + }, + { id: "c", status: "pending" as const, replay: "unknown" as const }, + ], + }), + }; + h.setConnection({ session: { ...h.session, live } }); + expect(h.doctor.snapshot().issues).toEqual([ + expect.objectContaining({ + id: "live", + quiet: false, + busy: false, + details: ["restricted: permission required", "Temporary failure"], + }), + ]); +}); + +it("rebinds subscriptions on session replacement and releases all of them on unmount", async () => { + const h = setup(), + next = setup(); + const listener = vi.fn(); + const stop = h.doctor.subscribe(listener); + h.setConnection({ session: next.session, generation: 1 }); + expect(h.doctor.snapshot().session).toBe(next.session); + listener.mockClear(); + next.failures.set(30622, new ReadError("denied", "No permission", 403)); + await next.session.channelLifecycle.refreshVisibility(); + expect(listener).toHaveBeenCalled(); + expect(h.doctor.snapshot().issues.map((issue) => issue.id)).toEqual([ + "visibility", + ]); + stop(); + listener.mockClear(); + next.failures.clear(); + await next.session.channelLifecycle.refreshVisibility(); + expect(listener).not.toHaveBeenCalled(); +}); + +it("does not retry a retired session's workspace projection after a held catalog read finishes", async () => { + const h = setup({ kit: true }), + next = setup(); + h.failures.set(30078, offline()); + await h.session.soloPreferences.refresh(); + h.doctor.snapshot(); + let release!: (events: []) => void; + let started!: () => void; + const reading = new Promise((resolve) => { + started = resolve; + }); + h.query.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + started(); + }), + ); + const retry = h.doctor.retry(); + await reading; + h.setConnection({ session: next.session, generation: 1 }); + expect(h.doctor.snapshot().busy).toBe(false); + release([]); + await retry; + expect(h.session.soloPreferences.snapshot().status).toBe("error"); +}); diff --git a/src/features/relay/doctor.ts b/src/features/relay/doctor.ts new file mode 100644 index 0000000000..ecd11d7995 --- /dev/null +++ b/src/features/relay/doctor.ts @@ -0,0 +1,394 @@ +import type { RelayData, RelaySnapshot } from "./service"; +import type { RelaySession } from "./session"; +import { isTransientLiveError } from "./live-recovery"; + +export type DoctorIssueId = + | "connection" + | "live" + | "catalog" + | "workspaces" + | "sidebar" + | "visibility" + | "agents"; +export type DoctorIssue = Readonly<{ + id: DoctorIssueId; + title: string; + details: readonly string[]; + busy: boolean; + quiet: boolean; + failure?: number; +}>; +type Source = Omit & { + failed: boolean; + transient?: boolean | undefined; + retry(): unknown; +}; + +function sources(connection: RelaySnapshot, relay: RelayData): Source[] { + const session = connection.session; + const state = session.live.snapshot(); + const choices = session.agentChoices.snapshot(); + const library = session.agentLibrary.snapshot(); + const liveErrors = [ + state.error, + state.roster.error, + ...state.routes + .filter((route) => route.status === "error") + .map((route) => route.error ?? "Conversation updates could not connect."), + ...state.heads + .filter((head) => head.state === "error") + .map((head) => head.error ?? "Messages could not refresh."), + ].filter((error): error is string => !!error); + const liveFailed = + liveErrors.length > 0 || + ["retrying", "error", "unavailable"].includes(state.status) || + ["error", "deferred"].includes(state.roster.state); + const liveRefused = + (state.status === "error" && !isTransientLiveError(state.error)) || + (state.roster.state === "error" && !state.roster.transient) || + state.heads.some((head) => head.state === "error" && !head.transient) || + state.routes.some( + (route) => route.status === "error" && !isTransientLiveError(route.error), + ); + const read = ( + id: DoctorIssueId, + title: string, + snapshot: { + status: string; + error?: string | undefined; + transient?: true | undefined; + }, + retry: Source["retry"], + ): Source => ({ + id, + title, + details: snapshot.error + ? [snapshot.error] + : [`${title} could not refresh.`], + failed: snapshot.status === "error" || !!snapshot.error, + busy: snapshot.status === "loading", + transient: snapshot.transient, + retry, + }); + return [ + { + id: "connection", + title: "Community connection", + details: [connection.error ?? "Your community could not connect."], + failed: connection.status === "error", + busy: connection.status === "connecting", + transient: connection.retrying, + retry: relay.retry, + }, + // A cached/offline session has no live transport. Connection recovery owns it. + ...(connection.cached || !session.recovery + ? [] + : [ + { + id: "live" as const, + title: "Conversations and live updates", + details: [ + ...new Set( + liveErrors.length + ? liveErrors + : ["Conversation updates could not connect."], + ), + ], + failed: liveFailed, + busy: + !liveFailed && + (state.status === "connecting" || + state.roster.state === "pending" || + state.heads.some((head) => head.state === "pending") || + state.routes.some((route) => route.status === "pending")), + // A refusal must remain visible even if another route is recovering. + transient: !liveRefused, + retry: session.live.retry, + }, + read( + "catalog", + "Channel templates", + session.channelKit.snapshot(), + () => session.channelKit.refresh(), + ), + read( + "workspaces", + "Workspaces", + session.soloPreferences.snapshot(), + () => session.soloPreferences.refresh(), + ), + read( + "sidebar", + "Saved sidebar preferences", + session.sidebarPreferences.snapshot(), + () => session.sidebarPreferences.refresh(), + ), + read( + "visibility", + "Hidden conversations", + session.channelLifecycle.snapshot(), + () => session.channelLifecycle.refreshVisibility(), + ), + { + ...read("agents", "Agent inventory", choices, () => + session.agentChoices.refresh(), + ), + // Choice inference also calls idle legacy evidence `pending`. + // Only join reads that have actually started. + busy: + choices.status === "loading" || + library.status === "loading" || + (library.status === "ready" && choices.pending), + }, + ]), + ]; +} + +const subscriptions = (session: RelaySession) => [ + session.live, + session.channelKit, + session.soloPreferences, + session.sidebarPreferences, + session.channelLifecycle, + session.agentChoices, + ...(session.recovery ? [session.recovery] : []), +]; + +/** Presentation and explicit read retries only. Sources keep their automatic + * recovery, coalescing, cancellation and partial data. No writes are replayed. */ +export function createDoctor( + relay: RelayData, + viewer?: string, + origin?: string, +) { + const listeners = new Set<() => void>(); + type Operation = { + session: RelaySession; + all: boolean; + active?: DoctorIssueId; + }; + const pending = new Map(); + const failures = new Map< + DoctorIssueId, + { session: RelaySession; count: number } + >(); + let revision = 0; + let inputs: readonly unknown[] = []; + let snapshot = { + session: relay.snapshot().session, + issues: [] as readonly DoctorIssue[], + busy: false, + retryingAll: false, + checking: relay.snapshot().status === "connecting", + }; + const emit = () => { + revision++; + for (const listener of listeners) listener(); + }; + const getSnapshot = () => { + const connection = relay.snapshot(), + session = connection.session; + const recovery = session.recovery?.snapshot(); + const nextInputs = [ + connection, + revision, + ...subscriptions(session).map((source) => source.snapshot()), + ]; + if ( + nextInputs.length === inputs.length && + nextInputs.every((value, index) => value === inputs[index]) + ) + return snapshot; + inputs = nextInputs; + if ( + (viewer && connection.viewer && viewer !== connection.viewer) || + (origin && connection.origin && origin !== connection.origin) + ) { + snapshot = { + session, + issues: [], + busy: false, + retryingAll: false, + checking: false, + }; + return snapshot; + } + // The host recreates this model when the visible account/community changes. + // A same-scope reconnect can replace the session before it knows its origin. + const previous: readonly DoctorIssue[] = + snapshot.session === session + ? snapshot.issues + : connection.status === "connecting" && snapshot.issues.length + ? [ + { + id: "connection", + title: "Community connection", + details: ["Reconnecting to your community…"], + busy: true, + quiet: false, + }, + ] + : []; + const issues = sources(connection, relay).flatMap( + (source): DoctorIssue[] => { + const old = previous.find((issue) => issue.id === source.id); + const operation = pending.get(source.id); + const busy = + operation?.session === session + ? operation.active === source.id + : source.busy; + if (!source.failed) + return busy && old + ? [{ ...old, busy, quiet: old.quiet && !recovery?.shown }] + : []; + return [ + { + id: source.id, + title: source.title, + details: source.details, + busy, + failure: + failures.get(source.id)?.session === session + ? (failures.get(source.id)?.count ?? 0) + : 0, + quiet: + !!source.transient && + !!recovery?.recovering && + !recovery.shown && + (!old || old.quiet), + }, + ]; + }, + ); + // A retry may have started before this host mounted, leaving only busy + // source snapshots. Keep the existing delayed recovery entry point. + if (recovery?.shown && !issues.length) + issues.push({ + id: "live", + title: "Conversations and live updates", + details: ["Reconnecting to your community…"], + busy: true, + quiet: false, + }); + snapshot = { + session, + issues, + busy: [...pending.values()].some( + (operation) => operation.session === session, + ), + retryingAll: [...pending.values()].some( + (operation) => operation.session === session && operation.all, + ), + checking: connection.status === "connecting", + }; + return snapshot; + }; + return { + snapshot: getSnapshot, + subscribe(listener: () => void) { + listeners.add(listener); + let session = relay.snapshot().session; + let stops = subscriptions(session).map((source) => + source.subscribe(listener), + ); + const stop = relay.subscribe(() => { + const current = relay.snapshot().session; + if (current !== session) { + for (const stop of stops) stop(); + session = current; + stops = subscriptions(session).map((source) => + source.subscribe(listener), + ); + } + listener(); + }); + return () => { + stop(); + for (const stop of stops) stop(); + listeners.delete(listener); + }; + }, + async retry(id?: DoctorIssueId) { + const connection = relay.snapshot(); + const session = connection.session; + // All Doctor entry points share one manual batch. Repeated clicks and + // individual actions cannot start another batch while this one is active. + if ( + [...pending.values()].some((operation) => operation.session === session) + ) + return; + const issues = getSnapshot().issues; + if (!issues.length) return; + const selected = sources(connection, relay).filter( + (source) => + (!id || + source.id === id || + (id === "catalog" && source.id === "workspaces")) && + (issues.some((issue) => issue.id === source.id) || + (!id && + source.busy && + source.id !== "connection" && + source.id !== "live")) && + !( + source.busy && + (source.id === "connection" || source.id === "live") + ), + ); + if (!selected.length) return; + const operation: Operation = { session, all: !id }; + for (const source of selected) pending.set(source.id, operation); + emit(); + const retry = async (source: Source) => { + await source.retry(); + // Join work already in flight. If that pre-click read then fails, + // make one fresh attempt rather than returning its stale failure. + if (source.busy && relay.snapshot().session === session) { + const current = sources(relay.snapshot(), relay).find( + (item) => item.id === source.id, + ); + if (current?.failed) await current.retry(); + } + }; + try { + // Sources are dependency ordered: connection/live, catalog, workspaces, + // then independent finite reads. Keep one finite source retry at a time. + // A catalog need not change its Solo head, so still refresh the failed + // workspace projection after it, even when that head is unchanged. + for (const source of selected) { + if (relay.snapshot().session !== session) break; + const current = sources(relay.snapshot(), relay).find( + (item) => item.id === source.id, + ); + // Earlier recovery may already have repaired this queued source. + if (!current || (!current.failed && !current.busy)) continue; + operation.active = source.id; + emit(); + try { + await retry(current); + } catch { + // Source snapshots retain their own failure; later issues can still recover. + } finally { + if ( + relay.snapshot().session === session && + sources(relay.snapshot(), relay).find( + (item) => item.id === source.id, + )?.failed + ) { + const previous = failures.get(source.id); + failures.set(source.id, { + session, + count: (previous?.session === session ? previous.count : 0) + 1, + }); + } + delete operation.active; + emit(); + } + } + } finally { + for (const source of selected) + if (pending.get(source.id) === operation) pending.delete(source.id); + emit(); + } + }, + }; +} diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index 8a0fca7936..6c124a9420 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -411,7 +411,10 @@ success messages around a routine task. Keep important scope, privacy, destructi consequences, and recovery copy; move it next to the relevant decision rather than deleting it to make a layout quieter. -Use inline feedback for lasting problems and shared Toast for transient results. +Use inline feedback for field validation, failed actions and object-specific +correction. Buzz Doctor consolidates background-read problems in one persistent +shared Toast, with Retry and a dialog of individual issues and actions. Use finite +shared Toast feedback for transient results. Do not build another notification stack. Preserve labels, focus, and control size while pending, and prevent repeated activation in behavior. Shared surfaces own their motion: keyboard/high-frequency interactions stay immediate, exits do not @@ -1015,7 +1018,8 @@ selection changes. Standalone icon actions continue to use `IconButton`. Disabled controls retain their filled, outline, or unfilled treatment and cannot activate. Loading retains the label's geometry, accessible name, focus and variant -colors while blocking activation; never swap in a differently sized loading label. +colors while blocking activation. Explicit `disabled` also applies disabled colors +while retaining the spinner. Never swap in a differently sized loading label. Pointer hover uses shared state timing; expanded triggers retain pressed emphasis. Keep keyboard-only focus and reduced-motion behavior owned by the system. @@ -1482,10 +1486,15 @@ Sonner is the toast behavior owner; other matching controls use Base UI. ToastNo owns its state and recovery: unmounting the source removes its notification, without reporting user dismissal. Gate notices from hidden Settings sections explicitly; portals do not inherit a hidden ancestor. Keep form errors and -blocked-page recovery inline. Channel LiveStatus is lasting, feature-owned recovery: -keep it in the conversation flow immediately above the composer, with bounded -overflow, so Send and companion-panel controls remain usable. Its retry policy -and diagnostics belong to Channels; it does not enter the floating toast stack. +blocked-page recovery inline. Buzz Doctor owns the single notice for community, +live-update, template, workspace, sidebar, hidden-conversation and agent-inventory +read failures. Its toast opens a shared Dialog listing each issue and its action; +the toast also offers a combined Retry. The existing source services retain +automatic recovery and cancellation. Suppress a duplicate source notice only +while Doctor represents that source; preserve unrelated refusals and partial data +in the details. Retain an issue during its pending retry. Keep field validation, +failed sends and object-specific operation recovery inline. Channel LiveStatus +retains diagnostics and passive coverage context, not a duplicate shared outage. A notice can make its body open a related destination with `navigation`. The body has a hover state and a keyboard action. Its explicit buttons and Dismiss diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index 53a32dfff1..6c9e545311 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -321,11 +321,17 @@ .buzz-button[data-disabled] .buzz-button-attention rect { animation: none; } - .buzz-button[data-variant="attention"][data-disabled]:not([data-loading]) + .buzz-button[data-variant="attention"][data-disabled]:not( + [data-loading]:not([data-disabled-appearance]) + ) .buzz-button-attention { opacity: 1; } - .buzz-button[data-disabled]:not([data-loading]) .buzz-button-attention rect { + .buzz-button[data-disabled]:not( + [data-loading]:not([data-disabled-appearance]) + ) + .buzz-button-attention + rect { stroke: var(--border-standard); } @media (prefers-reduced-motion: reduce) { @@ -706,8 +712,10 @@ background: var(--affordance-accent-hover); } - /* Disabled keeps variant geometry; loading keeps its available colors. */ - .buzz-button[data-disabled]:not([data-loading]) { + /* Loading keeps available colors unless the caller explicitly disables it. */ + .buzz-button[data-disabled]:not( + [data-loading]:not([data-disabled-appearance]) + ) { color: var(--text-unavailable); background: var(--affordance-disabled); cursor: not-allowed; @@ -719,14 +727,18 @@ [data-variant="attention"], [data-variant="link"] )[data-disabled]:not( - [data-loading], + [data-loading]:not([data-disabled-appearance]), [data-icon-variant="tint"], [data-icon-variant="chrome"] ) { background: transparent; } - .buzz-button[data-variant="prominent"][data-disabled]:not([data-loading]), - .buzz-button[data-variant="outline"][data-disabled]:not([data-loading]) { + .buzz-button[data-variant="prominent"][data-disabled]:not( + [data-loading]:not([data-disabled-appearance]) + ), + .buzz-button[data-variant="outline"][data-disabled]:not( + [data-loading]:not([data-disabled-appearance]) + ) { box-shadow: inset 0 0 0 1px var(--border-standard); } diff --git a/src/shared/design-system/ui/Button.tsx b/src/shared/design-system/ui/Button.tsx index e89a8eafae..8eb35f1a69 100644 --- a/src/shared/design-system/ui/Button.tsx +++ b/src/shared/design-system/ui/Button.tsx @@ -97,6 +97,7 @@ export function Button({ data-size={size === "compact" ? "sm" : size === "default" ? "md" : size} data-button-shape={shape} data-loading={loading || undefined} + data-disabled-appearance={disabled || undefined} > {variant === "attention" && ( { + await page.emulateMedia({ + colorScheme: "dark", + reducedMotion: "no-preference", + }); + await page.goto(app.origin); + await openPage(page, "Messages"); + await expect + .poll(() => + page.evaluate(() => { + const session = window.fixtureRelay.snapshot().session; + return [ + session.live.snapshot().status, + session.sidebarPreferences.snapshot().status, + session.channelLifecycle.snapshot().status, + ]; + }), + ) + .toEqual(["connected", "ready", "ready"]); + const gates = () => ({ + sidebar: { + started: Promise.withResolvers(), + done: Promise.withResolvers(), + }, + visibility: { + started: Promise.withResolvers(), + done: Promise.withResolvers(), + }, + }); + let pending = gates(); + let held = false; + let failing = true; + const order = []; + await page.route("**/api/relay/**/query", async (route) => { + const filters = route.request().postDataJSON(); + const source = filters.some((filter) => filter.kinds?.includes(30622)) + ? "visibility" + : filters.some((filter) => filter["#d"]?.includes("channel-sections")) + ? "sidebar" + : undefined; + if (!source || !failing) return route.continue(); + if (held) { + order.push(source); + pending[source].started.resolve(); + await pending[source].done.promise; + } + app.report.sidebarActivityFailures ??= []; + app.report.sidebarActivityFailures.push(route.request().url()); + return route.fulfill({ status: 403, json: { error: "Read refused" } }); + }); + await page.evaluate(async () => { + const session = window.fixtureRelay.snapshot().session; + await Promise.all([ + session.sidebarPreferences.refresh(), + session.channelLifecycle.refreshVisibility(), + ]); + }); + const toast = page.getByRole("dialog", { + name: "Buzz is having issues", + exact: true, + }); + await expect(toast).toBeVisible(); + await toast.screenshot({ + path: testInfo.outputPath("doctor-toast-dark.png"), + }); + await toast.getByRole("button", { name: "View issues", exact: true }).click(); + const doctor = page.getByRole("dialog", { name: "Buzz Doctor", exact: true }); + const all = doctor.getByRole("button", { name: "Retry all", exact: true }); + const sidebar = doctor.getByRole("button", { + name: "Retry sidebar preferences", + exact: true, + }); + const visibility = doctor.getByRole("button", { + name: "Retry hidden conversations", + exact: true, + }); + for (const control of [sidebar, visibility, all]) + await control.evaluate((button) => { + const animate = button.animate.bind(button); + button.animate = (...args) => { + const animation = animate(...args); + button.doctorAnimation = animation; + button.dataset.observedFeedback = String( + Number(button.dataset.observedFeedback ?? 0) + 1, + ); + return animation; + }; + }); + held = true; + try { + await sidebar.click(); + await pending.sidebar.started.promise; + await expect(all).toBeDisabled(); + await expect(all).not.toHaveAttribute("data-loading", "true"); + await expect(visibility).toBeDisabled(); + await expect(sidebar).toHaveAttribute("data-loading", "true"); + await doctor.screenshot({ + path: testInfo.outputPath("doctor-individual-retry-dark.png"), + }); + pending.sidebar.done.resolve(); + await expect(sidebar).toHaveAttribute("data-observed-feedback", "1"); + await expect(all).toBeEnabled(); + pending = gates(); + order.length = 0; + await all.click(); + await pending.sidebar.started.promise; + for (const control of [all, sidebar, visibility]) + await expect(control).toBeDisabled(); + await expect(all).toHaveAttribute("data-loading", "true"); + await expect(sidebar).toHaveAttribute("data-loading", "true"); + await expect(visibility).not.toHaveAttribute("data-loading", "true"); + await doctor.screenshot({ + path: testInfo.outputPath("doctor-retry-all-dark.png"), + }); + pending.sidebar.done.resolve(); + await pending.visibility.started.promise; + await expect(sidebar).toHaveAttribute("data-observed-feedback", "2"); + await expect(sidebar).toBeDisabled(); + await expect(sidebar).not.toHaveAttribute("data-loading", "true"); + await expect(visibility).toHaveAttribute("data-loading", "true"); + expect(order).toEqual(["sidebar", "visibility"]); + // Capture a native app-generated shake frame; resume playback afterwards. + await sidebar.evaluate((button) => { + button.doctorAnimation.pause(); + button.doctorAnimation.currentTime = 55; + }); + await doctor.screenshot({ + path: testInfo.outputPath("doctor-failed-row-dark.png"), + }); + await sidebar.evaluate((button) => button.doctorAnimation.play()); + await expect(all).not.toHaveAttribute("data-observed-feedback", "1"); + pending.visibility.done.resolve(); + await expect(visibility).toHaveAttribute("data-observed-feedback", "1"); + await expect(all).toHaveAttribute("data-observed-feedback", "1"); + await expect(all).toBeEnabled(); + } finally { + pending.sidebar.done.resolve(); + pending.visibility.done.resolve(); + held = false; + } + failing = false; + await all.click(); + await expect(doctor).toContainText("Everything is working again."); +}); + // App/session/broker wiring must expose exhausted visibility reads on the -// sidebar and let the existing action recover. Timing/error matrices stay in +// Doctor and let its combined action recover. Timing/error matrices stay in // service and mounted component tests. -test("hidden-conversation exhaustion exposes the existing action and manual retry recovers", async ({ +test("Buzz Doctor consolidates an exhausted read, preserves dialog history and retries it", async ({ page, app, -}) => { +}, testInfo) => { let failing = true; let reads = 0; await page.goto(app.origin); @@ -53,25 +203,80 @@ test("hidden-conversation exhaustion exposes the existing action and manual retr await page.evaluate(() => window.fixtureRelay.snapshot().session.channelLifecycle.refreshVisibility(), ); - const retry = page.getByRole("button", { - name: "Retry hidden conversations", + const toast = page.getByRole("dialog", { + name: "Buzz is having issues", exact: true, }); - await expect(retry).toBeVisible(); + await expect(toast).toBeVisible(); expect(reads).toBe(4); await expect( - page.getByRole("alert").filter({ - hasText: "Hidden conversations could not be refreshed.", - }), - ).toBeVisible(); + page.getByText("Hidden conversations could not be refreshed."), + ).toHaveCount(0); expect( await page.evaluate( () => window.fixtureRelay.snapshot().session.recovery.snapshot().recovering, ), ).toBe(false); + const input = page.getByRole("textbox", { + name: "Message #Alpha", + exact: true, + }); + await input.fill("Doctor keeps this draft"); + for (const [mode, width] of [ + ["light", 1440], + ["dark", 390], + ]) { + await page.emulateMedia({ colorScheme: mode }); + await page.setViewportSize({ width, height: 844 }); + await expect(page.locator("html")).toHaveAttribute("data-color-mode", mode); + const toastHost = toast.locator("xpath=ancestor::li[@data-sonner-toast]"); + await expect(toastHost).toHaveCSS("opacity", "1"); + await expect(toastHost).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)"); + await page.screenshot({ + path: testInfo.outputPath(`doctor-toast-${mode}-${width}.png`), + }); + await toast.screenshot({ + path: testInfo.outputPath(`doctor-toast-card-${mode}-${width}.png`), + }); + await toast + .getByRole("button", { name: "View issues", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { + name: "Buzz Doctor", + exact: true, + }); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveCSS("opacity", "1"); + await expect( + dialog.getByText("Hidden conversations", { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByRole("button", { + name: "Retry hidden conversations", + exact: true, + }), + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath(`doctor-dialog-${mode}-${width}.png`), + }); + await dialog.screenshot({ + path: testInfo.outputPath(`doctor-dialog-card-${mode}-${width}.png`), + }); + await page.goBack(); + await expect(dialog).toHaveCount(0); + await expect(input).toHaveText("Doctor keeps this draft"); + await page.goForward(); + await expect(dialog).toBeVisible(); + await dialog + .getByRole("button", { name: "Close Buzz Doctor", exact: true }) + .click(); + await expect(dialog).toHaveCount(0); + await page.goForward(); + await expect(dialog).toHaveCount(0); + } failing = false; - await retry.click(); + await toast.getByRole("button", { name: "Retry", exact: true }).click(); await expect .poll(() => page.evaluate( @@ -81,6 +286,7 @@ test("hidden-conversation exhaustion exposes the existing action and manual retr ), ) .toBe("ready"); - await expect(retry).toHaveCount(0); + await expect(toast).toHaveCount(0); + await expect(input).toHaveText("Doctor keeps this draft"); expect(reads).toBe(5); }); diff --git a/tests/browser/history-loading.spec.mjs b/tests/browser/history-loading.spec.mjs index c9a776e59f..f457431d38 100644 --- a/tests/browser/history-loading.spec.mjs +++ b/tests/browser/history-loading.spec.mjs @@ -24,10 +24,7 @@ tall( async ({ page, app }) => { await open(page, app); await expect( - page.getByRole("status", { - name: "Reconnecting to relay…", - exact: true, - }), + page.getByRole("dialog", { name: "Buzz is having issues", exact: true }), ).toHaveCount(0); await history(page).hover(); for (let i = 0; i < 100 && !app.pending.length; i++) { @@ -64,10 +61,7 @@ test("returning to the top continues history loading on wheel without a button", }) => { await open(page, app); await expect( - page.getByRole("status", { - name: "Reconnecting to relay…", - exact: true, - }), + page.getByRole("dialog", { name: "Buzz is having issues", exact: true }), ).toHaveCount(0); await history(page).hover(); await page.mouse.wheel(0, -100000); @@ -97,10 +91,7 @@ test("saved top resumes automatic history on an upward gesture after remount", a }) => { await open(page, app); await expect( - page.getByRole("status", { - name: "Reconnecting to relay…", - exact: true, - }), + page.getByRole("dialog", { name: "Buzz is having issues", exact: true }), ).toHaveCount(0); await history(page).hover(); // Establish the saved top with real input; remeasurement can consume part @@ -125,10 +116,7 @@ test("saved top resumes automatic history on an upward gesture after remount", a await openPage(page, "Messages"); await expect(history(page)).toBeVisible(); await expect( - page.getByRole("status", { - name: "Reconnecting to relay…", - exact: true, - }), + page.getByRole("dialog", { name: "Buzz is having issues", exact: true }), ).toHaveCount(0); await settle(page); await expect.poll(() => history(page).evaluate((e) => e.scrollTop)).toBe(0); @@ -147,10 +135,7 @@ test.describe("quota retry without threshold-driven continuation", () => { }) => { await open(page, app); await expect( - page.getByRole("status", { - name: "Reconnecting to relay…", - exact: true, - }), + page.getByRole("dialog", { name: "Buzz is having issues", exact: true }), ).toHaveCount(0); const olderAlpha = (filter) => filter.until !== undefined && diff --git a/tests/browser/live-status.spec.mjs b/tests/browser/live-status.spec.mjs index 0d70722171..e7c961374d 100644 --- a/tests/browser/live-status.spec.mjs +++ b/tests/browser/live-status.spec.mjs @@ -21,7 +21,7 @@ test.describe("held setup", () => { window.__bannerSeen = []; window.__bannerObserver = new MutationObserver(() => { for (const status of document.querySelectorAll( - '[role="status"][aria-label="Reconnecting to relay…"], [role="status"][aria-label="Live updates limited"]', + '[role="dialog"].buzz-toast, [role="status"][aria-label="Live updates limited"]', )) { window.__bannerSeen.push(status.textContent); } @@ -37,8 +37,9 @@ test.describe("held setup", () => { await expect( page.getByRole("textbox", { name: "Message #Alpha", exact: true }), ).toBeVisible(); - const warning = page.getByRole("status", { - name: /^(Reconnecting to relay…|Live updates limited)$/, + const warning = page.getByRole("dialog", { + name: "Buzz is having issues", + exact: true, }); await expect.poll(() => app.relay.hasRoute("primary", "beta")).toBe(true); await expect(warning).toHaveCount(0); @@ -87,9 +88,9 @@ test.describe("held setup", () => { }); const notice = (page) => - page.getByRole("status", { name: "Reconnecting to relay…", exact: true }); + page.getByRole("dialog", { name: "Buzz is having issues", exact: true }); const retryNow = (page) => - notice(page).getByRole("button", { name: "Retry now", exact: true }); + notice(page).getByRole("button", { name: "Retry", exact: true }); for (const target of ["alpha", "profiles"]) { test(`quota recovery for ${target} stays quiet until attempts exhaust, then keeps retrying automatically until EOSE`, async ({ @@ -164,7 +165,7 @@ for (const target of ["alpha", "profiles"]) { } } // The notice keeps showing while the retried REQ waits for EOSE, and it - // must not block the header, composer, or narrow navigation. + // remains bounded in the shared notification stack at every width. for (const width of [390, 800, 1440]) { await page.setViewportSize({ width, height: 950 }); await page @@ -179,8 +180,8 @@ for (const target of ["alpha", "profiles"]) { await expect(notice(page)).toHaveCount(1); // Diagnostics must not duplicate it. await expect(warning).toHaveCount(0); await expect( - page.getByRole("dialog", { name: "Reconnecting to relay…" }), - ).toHaveCount(0); // Nor a floating copy. + page.getByRole("status", { name: "Reconnecting to relay…" }), + ).toHaveCount(0); // No inline copy. await expect(notice(page)).toHaveCSS("opacity", "1"); const composer = page .getByRole("textbox", { @@ -191,16 +192,21 @@ for (const target of ["alpha", "profiles"]) { await expect .poll(async () => { const box = await notice(page).boundingBox(); - const form = await composer.boundingBox(); - return box && form ? box.y + box.height <= form.y : false; + return ( + !!box && + box.x >= 0 && + box.x + box.width <= width && + box.y >= 0 && + box.y + box.height <= 950 + ); }) .toBe(true); - await composer - .getByRole("button", { - name: "Send message", - exact: true, - }) - .click({ trial: true }); + const send = composer.getByRole("button", { + name: "Send message", + exact: true, + }); + await send.focus(); + await expect(send).toBeFocused(); await page.screenshot({ path: testInfo.outputPath(`recovery-app-${target}-${width}.png`), }); @@ -250,10 +256,9 @@ for (const target of ["alpha", "profiles"]) { }); } -// Browser layout: a long, unbroken relay reason stays out of the inline -// recovery notice, which sits above the composer with keyboard Retry now -// reachable at enlarged text size. -test("long recovery stays bounded above the composer at enlarged text size", async ({ +// Long relay reasons stay in Doctor details; the concise toast remains bounded +// and keyboard-accessible through the shared F6 notification navigation. +test("Buzz Doctor stays bounded and keyboard-accessible at enlarged text size", async ({ page, app, }, testInfo) => { @@ -286,17 +291,33 @@ test("long recovery stays bounded above the composer at enlarged text size", asy return ( !!box && !!composer && - box.y + box.height <= composer.y && + box.x >= 0 && + box.x + box.width <= 390 && + box.y >= 0 && + box.y + box.height <= 600 && composer.y + composer.height <= 600 ); }) .toBe(true); - await expect(warning).toHaveText(/^Reconnecting to relay…Retry now$/); - await form - .getByRole("button", { name: "Send message", exact: true }) - .click({ trial: true }); + await expect(warning).toContainText("Buzz is having issues"); + await expect(warning).not.toContainText("UnavailableUnavailable"); + const send = form.getByRole("button", { name: "Send message", exact: true }); + await send.focus(); + await expect(send).toBeFocused(); await input.focus(); - await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("F6"); + await expect( + page.getByRole("region", { name: "App notifications" }).getByRole("list"), + ).toBeFocused(); + await page.keyboard.press("Tab"); + await expect( + warning.locator("xpath=ancestor::li[@data-sonner-toast]"), + ).toBeFocused(); + await page.keyboard.press("Tab"); + await expect( + warning.getByRole("button", { name: "Open Buzz Doctor", exact: true }), + ).toBeFocused(); + await page.keyboard.press("Tab"); await expect(retryNow(page)).toBeFocused(); await expect(retryNow(page)).toBeInViewport(); await page.screenshot({ @@ -461,10 +482,13 @@ test.describe("public preview recovery", () => { await expect(warning).toBeVisible(); await expect .poll(async () => { - const capsule = await invitation.boundingBox(); const recovery = await warning.boundingBox(); return ( - !!capsule && !!recovery && capsule.y + capsule.height <= recovery.y + !!recovery && + recovery.x >= 0 && + recovery.x + recovery.width <= 390 && + recovery.y >= 0 && + recovery.y + recovery.height <= 700 ); }) .toBe(true); diff --git a/tests/browser/live.spec.mjs b/tests/browser/live.spec.mjs index 665df86341..23a3ae0dca 100644 --- a/tests/browser/live.spec.mjs +++ b/tests/browser/live.spec.mjs @@ -18,9 +18,9 @@ test.use({ const history = (page) => page.getByRole("region", { name: "Channel message history" }); const notice = (page) => - page.getByRole("status", { name: "Reconnecting to relay…", exact: true }); + page.getByRole("dialog", { name: "Buzz is having issues", exact: true }); const retry = (page) => - notice(page).getByRole("button", { name: "Retry now", exact: true }); + notice(page).getByRole("button", { name: "Retry", exact: true }); /** Retry now if the notice still shows; automatic recovery may close it first. */ const retryIfShown = (page) => retry(page) @@ -305,109 +305,130 @@ test("a reconnect near startup repairs an under-limit replay and keeps paged his ).toBeInViewport(); }); -test("automatic recovery restores an empty paused roster without restarting healthy global subscriptions", async ({ - page, - app, -}) => { - // Empty is a valid authoritative result: recovery must not depend on a - // selected channel or on an interest change replacing the global stream. - app.relay.emptyRoster(); - app.relay.quotaNextRoster(6); - // The app arms its cooldown when it consumes the refusal, and the ignored - // clicks below must land before that deadline. Navigation needs a running - // page clock, so hold the first roster read until it is done, then pause the - // clock before the refusal arms the deadline: only crossCooldown passes it. - const roster = Promise.withResolvers(); - let rosterHeld = false; - await page.route("**/api/relay/primary/query", async (route) => { - if ( - !rosterHeld && - [route.request().postDataJSON()] - .flat() - .some((filter) => filter.kinds?.includes(39002)) - ) { - rosterHeld = true; - await roster.promise; +const observedRoster = test.extend({ pluginFixtures: true }); +observedRoster( + "automatic recovery restores an empty paused roster without restarting healthy global subscriptions", + async ({ page, app }) => { + // Empty is a valid authoritative result: recovery must not depend on a + // selected channel or on an interest change replacing the global stream. + app.relay.emptyRoster(); + app.relay.quotaNextRoster(6); + // The app arms its cooldown when it consumes the refusal, and the ignored + // clicks below must land before that deadline. Navigation needs a running + // page clock, so hold the first roster read until it is done, then pause the + // clock before the refusal arms the deadline: only crossCooldown passes it. + const roster = Promise.withResolvers(); + let rosterHeld = false; + await page.route("**/api/relay/primary/query", async (route) => { + if ( + !rosterHeld && + [route.request().postDataJSON()] + .flat() + .some((filter) => filter.kinds?.includes(39002)) + ) { + rosterHeld = true; + await roster.promise; + } + await route.fallback(); + }); + await page.clock.install(); + await page.goto(app.origin); + try { + await openPage(page, "Messages"); + await expect.poll(() => rosterHeld).toBe(true); + await pauseClock(page, 1000); + } finally { + roster.resolve(); } - await route.fallback(); - }); - await page.clock.install(); - await page.goto(app.origin); - try { - await openPage(page, "Messages"); - await expect.poll(() => rosterHeld).toBe(true); - await pauseClock(page, 1000); - } finally { - roster.resolve(); - } - await expect.poll(() => app.relay.rejected.length).toBe(1); - // The notice appears once recovery lasts 3 s (RECOVERY_NOTICE_DELAY_MS). - // Run the paused clock only that far: the 6 s cooldown still holds, so the - // ignored clicks below land before it ends. - await page.clock.runFor(3_000); - await expect(notice(page)).toBeVisible(); - await expect( - page.getByRole("button", { name: "Alpha", exact: true }), - ).toHaveCount(0); - const globals = () => - app.relay.requests.filter(({ filters }) => - filters.some( - (filter) => filter.kinds.includes(0) || filter.kinds.includes(44100), + await expect.poll(() => app.relay.rejected.length).toBe(1); + // A server rejection precedes consumption and the batched source notification. + // Drive frames only until the actual recovery owner observes that failure. + await expect + .poll(async () => { + await page.clock.runFor(16); + return page.evaluate( + () => + window.fixtureRelay.snapshot().session.recovery.snapshot() + .recovering, + ); + }) + .toBe(true); + await expect(notice(page)).toHaveCount(0); + // The notice appears once recovery lasts 3 s (RECOVERY_NOTICE_DELAY_MS). + // Run the paused clock only that far: the 6 s cooldown still holds, so the + // ignored clicks below land before it ends. + await page.clock.runFor(3_000); + expect( + await page.evaluate( + () => window.fixtureRelay.snapshot().session.recovery.snapshot().shown, ), - ); - const observer = () => - app.relay.requests.filter(({ filters }) => - filters.some((filter) => filter.kinds.includes(24200)), - ); - const presence = () => - app.relay.requests.filter(({ filters }) => - filters.some((filter) => filter.kinds.includes(20001)), - ); - await expect.poll(() => globals().length).toBe(2); - await expect.poll(() => observer().length).toBe(1); - await expect.poll(() => presence().length).toBe(1); - // Presence owns a separate access/demand-scoped route. Establish startup EOSE - // before recording healthy chat wires; a roster error is not a stream barrier. - const healthy = globals(); - await expect - .poll(() => - [...healthy, ...presence(), ...observer()].every(({ id }) => + ).toBe(true); + // Toast publication follows the source transition through its portal owner. + await page.clock.runFor(16); + await expect(notice(page)).toBeVisible(); + await expect( + page.getByRole("button", { name: "Alpha", exact: true }), + ).toHaveCount(0); + const globals = () => + app.relay.requests.filter(({ filters }) => + filters.some( + (filter) => filter.kinds.includes(0) || filter.kinds.includes(44100), + ), + ); + const observer = () => + app.relay.requests.filter(({ filters }) => + filters.some((filter) => filter.kinds.includes(24200)), + ); + const presence = () => + app.relay.requests.filter(({ filters }) => + filters.some((filter) => filter.kinds.includes(20001)), + ); + await expect.poll(() => globals().length).toBe(2); + await expect.poll(() => observer().length).toBe(1); + await expect.poll(() => presence().length).toBe(1); + // Presence owns a separate access/demand-scoped route. Establish startup EOSE + // before recording healthy chat wires; a roster error is not a stream barrier. + const healthy = globals(); + await expect + .poll(() => + [...healthy, ...presence(), ...observer()].every(({ id }) => + app.report.wireFrames.some( + (frame) => frame[0] === "EOSE" && frame[1] === id, + ), + ), + ) + .toBe(true); + const sockets = app.relay.sockets.length; + const rosters = () => + app.report.queries.filter(({ filter }) => filter.kinds?.includes(39002)); + const calls = rosters().length; + await retry(page).click(); + await retry(page).click(); + expect(rosters()).toHaveLength(calls); + await crossCooldown(page, app); + await retryIfShown(page); + await expect( + page.getByText("No channels yet.", { exact: true }), + ).toBeVisible(); + await expect(notice(page)).toHaveCount(0); + expect(rosters()).toHaveLength(calls + 1); + expect(app.relay.sockets).toHaveLength(sockets); + // The first authoritative (empty) roster resets the access generation: + // activity renews its live-only route. That lifecycle may not restart the + // healthy profile/membership chat globals. + await expect.poll(() => observer().length).toBe(2); + await expect + .poll(() => app.report.wireFrames.some( - (frame) => frame[0] === "EOSE" && frame[1] === id, + (frame) => frame[0] === "EOSE" && frame[1] === observer()[1].id, ), - ), - ) - .toBe(true); - const sockets = app.relay.sockets.length; - const rosters = () => - app.report.queries.filter(({ filter }) => filter.kinds?.includes(39002)); - const calls = rosters().length; - await retry(page).click(); - await retry(page).click(); - expect(rosters()).toHaveLength(calls); - await crossCooldown(page, app); - await retryIfShown(page); - await expect( - page.getByText("No channels yet.", { exact: true }), - ).toBeVisible(); - await expect(notice(page)).toHaveCount(0); - expect(rosters()).toHaveLength(calls + 1); - expect(app.relay.sockets).toHaveLength(sockets); - // The first authoritative (empty) roster resets the access generation: - // activity renews its live-only route. That lifecycle may not restart the - // healthy profile/membership chat globals. - await expect.poll(() => observer().length).toBe(2); - await expect - .poll(() => - app.report.wireFrames.some( - (frame) => frame[0] === "EOSE" && frame[1] === observer()[1].id, - ), - ) - .toBe(true); - expect(globals()).toHaveLength(2); - for (const { socket, id } of healthy) - expect(app.relay.sockets[socket].routes.has(id)).toBe(true); - expect(observer()[1].filters[0].since).toBeGreaterThanOrEqual( - observer()[0].filters[0].since, - ); -}); + ) + .toBe(true); + expect(globals()).toHaveLength(2); + for (const { socket, id } of healthy) + expect(app.relay.sockets[socket].routes.has(id)).toBe(true); + expect(observer()[1].filters[0].since).toBeGreaterThanOrEqual( + observer()[0].filters[0].since, + ); + }, +); diff --git a/tests/browser/navigation-groups.spec.mjs b/tests/browser/navigation-groups.spec.mjs index 5cfbb7b5ff..80cb38df6e 100644 --- a/tests/browser/navigation-groups.spec.mjs +++ b/tests/browser/navigation-groups.spec.mjs @@ -923,9 +923,11 @@ test("Create new retains its draft when preferences fail before submission and r held.resolve(); await failed; // The one recovery notice reports it; there is no separate preferences toast. - // The modal makes the page inert, so match the inline notice by attribute. + // The modal makes notifications inert, so match the toast by its DOM class. await expect( - page.locator('[role="status"][aria-label="Reconnecting to relay…"]'), + page + .locator('[role="dialog"].buzz-toast') + .filter({ hasText: "Buzz is having issues" }), ).toBeVisible(); await expect( dialog.getByRole("button", { name: "Create and move" }), diff --git a/tests/browser/presence.spec.mjs b/tests/browser/presence.spec.mjs index 686992adb9..6bdc8906e5 100644 --- a/tests/browser/presence.spec.mjs +++ b/tests/browser/presence.spec.mjs @@ -11,10 +11,7 @@ test("profile snapshot and same-socket renewal coexist with real chat while opti await expect.poll(() => app.relay.hasRoute("primary", "alpha")).toBe(true); await expect.poll(() => app.relay.hasRoute("primary", "beta")).toBe(true); await expect( - page.getByRole("status", { - name: "Reconnecting to relay…", - exact: true, - }), + page.getByRole("dialog", { name: "Buzz is having issues", exact: true }), ).toHaveCount(0); await settle(page); try { diff --git a/tests/browser/relay-recovery.spec.mjs b/tests/browser/relay-recovery.spec.mjs index dd3999a43a..858db15042 100644 --- a/tests/browser/relay-recovery.spec.mjs +++ b/tests/browser/relay-recovery.spec.mjs @@ -5,8 +5,9 @@ test.use({ productionBroker: true, historyCounts: { alpha: 1, beta: 1 } }); // Models a laptop that wakes before its VPN is back: the relay connection // drops and every relay request gets a non-relay 403 page. When the network -// returns, the app must recover by itself, with no restart and no Retry click. -test("relay reads recover automatically after the network returns", async ({ +// returns, live reads must recover without a click. Doctor also represents +// finite/dependent reads that the older live-only notice did not cover. +test("live reads recover automatically and Doctor can retry remaining read failures", async ({ page, app, }, testInfo) => { @@ -41,20 +42,20 @@ test("relay reads recover automatically after the network returns", async ({ "alpha", Array.from({ length: 500 }, () => missed), ); - // With a conversation open, the notice sits inline above the composer. - const notice = page.getByRole("status", { - name: "Reconnecting to relay…", + // Background failures share the Doctor toast, including with a conversation open. + const notice = page.getByRole("dialog", { + name: "Buzz is having issues", exact: true, }); await expect(notice).toBeVisible({ timeout: 30_000 }); - // One notice and one Retry now; no per-panel Retry buttons. + // One notice and one Retry; no per-panel Retry buttons. await expect(page.getByRole("button", { name: /Retry/ })).toHaveCount(1); await expect( - page.getByRole("button", { name: "Retry now", exact: true }), + page.getByRole("button", { name: "Retry", exact: true }), ).toBeVisible(); - // The notice is the one place that reports it: no floating duplicate. + // The notice is the one place that reports it: no inline duplicate. await expect( - page.getByRole("dialog", { name: "Reconnecting to relay…", exact: true }), + page.getByRole("status", { name: "Reconnecting to relay…", exact: true }), ).toHaveCount(0); // Loaded messages and channels stay on screen. await expect(page.getByText("primary alpha message 0")).toBeVisible(); @@ -64,13 +65,42 @@ test("relay reads recover automatically after the network returns", async ({ await page.screenshot({ path: testInfo.outputPath("offline.png") }); // It leaves the composer and Send usable. await composer.fill("Draft while offline"); - await page - .getByRole("button", { name: "Send message", exact: true }) - .click({ trial: true }); + const send = page.getByRole("button", { name: "Send message", exact: true }); + await send.focus(); + await expect(send).toBeFocused(); await composer.fill(""); offline = false; - // No click: the scheduled retry finds the network back. + // This newly appended message proves automatic live/head recovery before + // any Doctor action; hiding a notice alone would not establish recovery. + await expect( + page.getByText("Missed while asleep", { exact: true }), + ).toBeVisible({ timeout: 45_000 }); + if (await notice.isVisible()) { + await notice + .getByRole("button", { name: "View issues", exact: true }) + .click(); + const doctor = page.getByRole("dialog", { + name: "Buzz Doctor", + exact: true, + }); + await expect(doctor).toBeVisible(); + app.report.measurements.push({ + remainingDoctorIssues: await doctor.innerText(), + }); + await doctor + .getByRole("button", { name: "Retry all", exact: true }) + .click(); + await expect( + doctor.getByText( + "Everything is working again. No issues need attention.", + { exact: true }, + ), + ).toBeVisible(); + await doctor + .getByRole("button", { name: "Close Buzz Doctor", exact: true }) + .click(); + } await expect(notice).toHaveCount(0, { timeout: 45_000 }); await expect(page.getByRole("button", { name: /Retry/ })).toHaveCount(0); // Every failed panel clears, including the open timeline. diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx index 7ea264478b..657089615a 100644 --- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx +++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx @@ -157,6 +157,11 @@ export function ButtonSpecimen() { Continue + + + Retry + +
{snapshot.error ?? "Workspaces couldn’t load."}
- {kitState.error}{" "} - void queries.channelKit.refresh()}> - Retry templates - -
+ {kitState.error}{" "} + void queries.channelKit.refresh()}> + Retry templates + +
{list.error}
Loading conversations…
{label}