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."}

-

- )} + {kitState.status === "error" && + !kitState.transient && + !doctorCatalog && ( +

+ {kitState.error}{" "} + +

+ )} {setupNotices.map((notice) => ( ))} - {!cached && dmVisibility.status === "error" && ( + {!cached && dmVisibility.status === "error" && !doctorVisibility && (
Hidden conversations could not be refreshed.{" "} + + ); +} + +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 ( +