Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7737075
Keep cached page data available during background refreshes
brsbl Sep 18, 2026
0207889
Wait for query observer notification after retry
brsbl Sep 18, 2026
1238c34
Retain cached project and plugin lists on refresh failure
brsbl Sep 18, 2026
9fe8fa8
Retain thread history during background refreshes
brsbl Sep 18, 2026
ba30ba6
Settle history pagination after cache admission
brsbl Sep 18, 2026
382b193
Avoid extra commits when seeding cached history
brsbl Sep 18, 2026
df2c1ca
Realize restored history when virtual scrolling settles
brsbl Sep 18, 2026
0929a98
Simplify history cache helpers and regression coverage
brsbl Sep 18, 2026
e93d7bd
Keep history cancellation inside its cache owner
brsbl Sep 18, 2026
5377264
Fix deep history recovery and expose latest navigation
brsbl Sep 19, 2026
6344dba
Keep timeline reconciliation behind the thread route
brsbl Sep 19, 2026
d11c38e
Trim duplicate timeline test setup and coverage
brsbl Sep 19, 2026
c7fa336
Unify cached history reconciliation and query completion
brsbl Sep 19, 2026
c630d09
Invalidate cached timeline cursors after thread renames
brsbl Sep 19, 2026
12110a7
Consolidate history refresh branches and query test setup
brsbl Sep 19, 2026
9d6921c
Reduce page resilience to existing cached query reads
brsbl Sep 19, 2026
19a8115
Preserve cached plugin settings and project defaults on refresh failure
brsbl Sep 19, 2026
586bdf1
Keep cached registry skill content on failed refresh
brsbl Sep 19, 2026
4a0b342
Retain cached thread pages across longer navigation gaps
brsbl Sep 19, 2026
ffd3aa5
Preserve SDK error types in thread cache regression
brsbl Sep 19, 2026
da2f81e
Keep skill and automation details visible after failed refreshes
brsbl Sep 19, 2026
889c424
Provide host roster for the skill cache regression
brsbl Sep 19, 2026
94d252b
Contain page download failures without losing app navigation
brsbl Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { AppCommandProvider } from "./components/commands/AppCommandProvider";
import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provider-cli-install";
import { ServerMoveOverlay } from "./components/machines/ServerMoveOverlay";
import { RouteLoadingSkeleton } from "./components/ui/route-loading-skeleton";
import { RouteContent } from "./components/RouteContent";

const SettingsView = lazy(() =>
import("./views/SettingsView").then((m) => ({
Expand Down Expand Up @@ -264,7 +265,7 @@ export function HashNavigationScroll() {
export function AppRoutes() {
return (
<AppLayout>
<Suspense fallback={null}>
<RouteContent>
<Routes>
<Route
path="/settings/usage"
Expand Down Expand Up @@ -398,7 +399,7 @@ export function AppRoutes() {
/>
</Routes>
<RouteContentPaintSignal />
</Suspense>
</RouteContent>
</AppLayout>
);
}
Expand Down
26 changes: 24 additions & 2 deletions apps/app/src/components/AppErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,53 @@ import { Component, type ErrorInfo, type ReactNode } from "react";

interface AppErrorBoundaryProps {
children: ReactNode;
fallback?: (error: Error) => ReactNode;
resetKey?: string;
}

interface AppErrorBoundaryState {
error: Error | null;
resetKey?: string;
}

export class AppErrorBoundary extends Component<
AppErrorBoundaryProps,
AppErrorBoundaryState
> {
override state: AppErrorBoundaryState = { error: null };
override state: AppErrorBoundaryState = {
error: null,
resetKey: this.props.resetKey,
};

static getDerivedStateFromProps(
props: AppErrorBoundaryProps,
state: AppErrorBoundaryState,
): AppErrorBoundaryState | null {
return props.resetKey === state.resetKey
? null
: { error: null, resetKey: props.resetKey };
}

static getDerivedStateFromError(error: unknown): AppErrorBoundaryState {
return { error: error instanceof Error ? error : new Error(String(error)) };
}

override componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("[bb] the app crashed", error, info.componentStack);
console.error(
this.props.fallback ? "[bb] a page failed to load" : "[bb] the app crashed",
error,
info.componentStack,
);
}

override render(): ReactNode {
const { error } = this.state;
if (error === null) {
return this.props.children;
}
if (this.props.fallback) {
return this.props.fallback(error);
}
return (
<div className="flex h-dvh w-full items-center justify-center bg-background p-6 text-foreground">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6">
Expand Down
89 changes: 89 additions & 0 deletions apps/app/src/components/RouteContent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @vitest-environment jsdom

import { lazy, useState } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Link, MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppErrorBoundary } from "./AppErrorBoundary";
import { RouteContent } from "./RouteContent";

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

describe("RouteContent", () => {
it.each([
"Failed to fetch dynamically imported module: /assets/settings.js",
"Importing a module script failed.",
"error loading dynamically imported module: /assets/settings.js",
"Unable to preload CSS for /assets/settings.css",
])("contains a failed page download: %s", async (message) => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const FailedPage = lazy(async () => {
throw new TypeError(message);
});
render(
<AppErrorBoundary>
<MemoryRouter initialEntries={["/failed"]}>
<nav>
<Link to="/working">Working page</Link>
</nav>
<RouteContent>
<Routes>
<Route path="/failed" element={<FailedPage />} />
<Route path="/working" element={<h1>Loaded content</h1>} />
</Routes>
</RouteContent>
</MemoryRouter>
</AppErrorBoundary>,
);

expect((await screen.findByRole("alert")).textContent).toContain(
"Couldn't load this page.",
);
expect(screen.getByRole("button", { name: "Reload page" })).toBeTruthy();
expect(screen.queryByText("bb hit an error and stopped")).toBeNull();
fireEvent.click(screen.getByRole("link", { name: "Working page" }));
expect(
await screen.findByRole("heading", { name: "Loaded content" }),
).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});

it("preserves mounted content across healthy navigation", () => {
function Page() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count {count}</button>;
}
render(
<MemoryRouter>
<Link to="/?view=other">Change view</Link>
<RouteContent>
<Page />
</RouteContent>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "Count 0" }));
fireEvent.click(screen.getByRole("link", { name: "Change view" }));
expect(screen.getByRole("button", { name: "Count 1" })).toBeTruthy();
});

it("leaves ordinary render failures to the app recovery screen", () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
function BrokenPage(): never {
throw new Error("render exploded");
}
render(
<AppErrorBoundary>
<MemoryRouter>
<RouteContent>
<BrokenPage />
</RouteContent>
</MemoryRouter>
</AppErrorBoundary>,
);
expect(screen.getByText("bb hit an error and stopped")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Reload page" })).toBeNull();
});
});
47 changes: 47 additions & 0 deletions apps/app/src/components/RouteContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Suspense, useEffect, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@bb/shared-ui/button";
import { EmptyStatePanel } from "@bb/shared-ui/empty-state";
import { markRouteContentPainted } from "@/lib/route-content-paint";
import { AppErrorBoundary } from "./AppErrorBoundary";

function PageLoadError() {
useEffect(() => {
markRouteContentPainted();
}, []);

return (
<EmptyStatePanel role="alert" className="mx-auto my-6 w-full max-w-xl">
<p>Couldn't load this page.</p>
<p className="mt-2">Check your connection, then reload to try again.</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => window.location.reload()}
>
Reload page
</Button>
</EmptyStatePanel>
);
}

function renderPageLoadError(error: Error): ReactNode {
if (
!/^(Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS for)/.test(
error.message,
)
) {
throw error;
}
return <PageLoadError />;
}

export function RouteContent({ children }: { children: ReactNode }) {
const location = useLocation();
return (
<AppErrorBoundary resetKey={location.key} fallback={renderPageLoadError}>
<Suspense fallback={null}>{children}</Suspense>
</AppErrorBoundary>
);
}
35 changes: 24 additions & 11 deletions apps/app/src/components/plugin/PluginSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -738,19 +738,22 @@ describe("PluginSettingsPage", () => {
expect(screen.queryByTestId("plugin-settings-skeleton")).toBeNull();
});

it("keeps loaded settings visible when a background plugin-list refresh fails", async () => {
let pluginListRequests = 0;
it("keeps loaded settings through a failed refresh and updates on recovery", async () => {
let failRefresh = false;
let greeting = "hello";
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "/api/v1/plugins/linear/settings") {
return jsonOk(SETTINGS_VIEW);
if (failRefresh) {
return new Response("unavailable", { status: 503 });
}
pluginListRequests += 1;
if (pluginListRequests === 1) {
return jsonOk({ plugins: [installedPlugin(true)] });
if (url === "/api/v1/plugins/linear/settings") {
return jsonOk({
...SETTINGS_VIEW,
values: { ...SETTINGS_VIEW.values, greeting },
});
}
throw new Error("offline");
return jsonOk({ plugins: [installedPlugin(true)] });
}),
);

Expand All @@ -764,12 +767,22 @@ describe("PluginSettingsPage", () => {
</MemoryRouter>,
);

expect(await screen.findByRole("heading", { name: "Linear" })).toBeTruthy();

await queryClient.invalidateQueries();
expect(await screen.findByDisplayValue("hello")).toBeTruthy();
failRefresh = true;
await act(async () => {
await queryClient.invalidateQueries();
});

expect(screen.getByRole("heading", { name: "Linear" })).toBeTruthy();
expect(screen.getByDisplayValue("hello")).toBeTruthy();
expect(screen.queryByText("Could not load plugin settings.")).toBeNull();

failRefresh = false;
greeting = "updated";
await act(async () => {
await queryClient.invalidateQueries();
});
expect(await screen.findByDisplayValue("updated")).toBeTruthy();
});

it("keeps the not-installed message free of loading affordances", async () => {
Expand Down
28 changes: 28 additions & 0 deletions apps/app/src/components/plugin/PluginsOverview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { makeSystemConfig } from "@/test/fixtures/system-config";
import { SidebarHistoryNavigationControls } from "@/components/sidebar/SidebarHistoryNavigationControls";
import { resetAppRouteHistoryForTest } from "@/lib/app-route-history";
import { pluginListQueryKey } from "@/hooks/queries/query-keys";
import { PluginsOverview } from "./PluginsOverview";

vi.mock("@/components/plugin/PluginNewThreadComposer", () => ({
Expand Down Expand Up @@ -198,6 +199,33 @@ afterEach(() => {
});

describe("PluginsOverview", () => {
it("keeps installed plugins visible when a background refresh fails", async () => {
installFetch();
const { wrapper, queryClient } = createQueryClientTestHarness();
render(
<MemoryRouter initialEntries={["/plugins?view=installed"]}>
<PluginsOverview />
</MemoryRouter>,
{ wrapper },
);
await screen.findByTestId("plugin-row-automations");
vi.mocked(fetch).mockResolvedValue(
responseJson({ error: "refresh failed" }, 503),
);
await act(async () => {
await queryClient.invalidateQueries({
queryKey: pluginListQueryKey(true),
});
});
await waitFor(() =>
expect(queryClient.getQueryState(pluginListQueryKey(true))?.status).toBe(
"error",
),
);
expect(screen.getByTestId("plugin-row-automations")).toBeTruthy();
expect(screen.queryByText("Couldn't load plugins.")).toBeNull();
});

it("opens on Browse and renders it before Installed", async () => {
installFetch();
const { wrapper: QueryClientWrapper } = createQueryClientTestHarness();
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/components/plugin/PluginsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ export function PluginsOverview({
}
>
<div className={cn("space-y-3", TOOLS_PAGE_BAND_CLASSES)}>
{listQuery.isError ? (
{listQuery.isError && listQuery.data === undefined ? (
<ResourceListState
state="error"
message="Couldn't load plugins."
Expand Down
32 changes: 30 additions & 2 deletions apps/app/src/components/settings/ProjectsSettingsSection.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom

import {
act,
cleanup,
fireEvent,
render,
Expand All @@ -13,6 +14,11 @@ import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sdk } from "@/lib/sdk";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { sidebarNavigationQueryKey } from "@/hooks/queries/query-keys";
import {
resetSidebarBootstrapCacheForTest,
SIDEBAR_BOOTSTRAP_CACHE_KEY,
} from "@/lib/sidebar-bootstrap-cache";
import { makeSystemConfig } from "@/test/fixtures/system-config";
import {
buildProjectReorderRequest,
Expand Down Expand Up @@ -145,13 +151,14 @@ const projects: SidebarProjectFixture[] = [
];

function renderSection() {
const { wrapper } = createQueryClientTestHarness();
return render(
const { wrapper, queryClient } = createQueryClientTestHarness();
const view = render(
<MemoryRouter>
<ProjectsSettingsSection />
</MemoryRouter>,
{ wrapper },
);
return { ...view, queryClient };
}

async function openProjectMenu(projectName: string): Promise<void> {
Expand All @@ -173,6 +180,8 @@ beforeEach(() => {

afterEach(() => {
cleanup();
resetSidebarBootstrapCacheForTest();
window.localStorage.removeItem(SIDEBAR_BOOTSTRAP_CACHE_KEY);
vi.unstubAllGlobals();
vi.clearAllMocks();
});
Expand Down Expand Up @@ -266,6 +275,25 @@ describe("formatGitRemote", () => {
});

describe("ProjectsSettingsSection", () => {
it("keeps cached projects visible when a background refresh fails", async () => {
stubSidebarBootstrapFetch(projects);
const { queryClient } = renderSection();
await screen.findByRole("link", { name: "Open bb settings" });
stubSidebarBootstrapFetch([], 503);
await act(async () => {
await queryClient.invalidateQueries({
queryKey: sidebarNavigationQueryKey(),
});
});
await waitFor(() =>
expect(
queryClient.getQueryState(sidebarNavigationQueryKey())?.status,
).toBe("error"),
);
expect(screen.getByRole("link", { name: "Open bb settings" })).toBeTruthy();
expect(screen.queryByText("Couldn't load projects.")).toBeNull();
});

it("summarises each project's remote, machine coverage, and threads", async () => {
stubSidebarBootstrapFetch(projects);

Expand Down
Loading
Loading