Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
82 changes: 73 additions & 9 deletions apps/web/src/pages/insights-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export function InsightsRunsHistory({
loading,
nextCursor,
onOpenRun,
embedded = false,
}: {
readonly runs: readonly InsightsRun[];
readonly loading: boolean;
Expand All @@ -212,14 +213,23 @@ export function InsightsRunsHistory({
* so the view says so instead of silently truncating at 100. */
readonly nextCursor: string | null;
readonly onOpenRun: (id: string) => void;
/** Rendered inside the Settings dialog: crumbs carry no `href` (a link
* would navigate the host app out from under the dialog) and the host
* shell's sidebar toggle is hidden. */
readonly embedded?: boolean;
}) {
const purpose = purposeRunsForInsights(runs);
const groups = groupRunsByDefinition(purpose);
return (
<div className="page-frame">
<StageTopBar
crumbs={[{ label: "Insights", href: INSIGHTS_PATH_PREFIX }, { label: "Run history" }]}
crumbs={
embedded
? [{ label: "Insights" }, { label: "Run history" }]
: [{ label: "Insights", href: INSIGHTS_PATH_PREFIX }, { label: "Run history" }]
}
subtitle={`${purpose.length} runs`}
hideSidebarToggle={embedded}
/>
<div className="page-scroll">
<PageShell width="full" className="page-fill">
Expand Down Expand Up @@ -323,19 +333,37 @@ function RunFailureDetail({
export function InsightsRunDetail({
run,
tenantId,
embedded = false,
onBack,
}: {
readonly run: InsightsRun | null;
readonly tenantId: string | null;
/** Same embedded contract as `InsightsRunsHistory`: no crumb links, no
* host sidebar toggle — plus a way back, since the host URL stays put. */
readonly embedded?: boolean;
readonly onBack?: () => void;
}) {
const failed = run !== null && insightsRunStatus(run) === "failed";
return (
<div className="page-frame">
<StageTopBar
crumbs={[
{ label: "Runs", href: INSIGHTS_RUNS_PATH },
{ label: run !== null ? runDisplayName(run) : "Run" },
]}
crumbs={
embedded
? [{ label: "Runs" }, { label: run !== null ? runDisplayName(run) : "Run" }]
: [
{ label: "Runs", href: INSIGHTS_RUNS_PATH },
{ label: run !== null ? runDisplayName(run) : "Run" },
]
}
subtitle={run !== null ? formatWhen(run.createdAt) : null}
hideSidebarToggle={embedded}
actions={
embedded && onBack !== undefined ? (
<button type="button" className="insights-link-button" onClick={onBack}>
Back to runs
</button>
) : undefined
}
/>
<div className="page-scroll">
<PageShell width="full" className="page-fill">
Expand Down Expand Up @@ -371,21 +399,27 @@ export function InsightsPage({
path,
runs,
tenantId = null,
embedded = false,
}: {
readonly path: string;
readonly runs: APIQuery<{
data: readonly InsightsRun[];
nextCursor: string | null;
}>;
readonly tenantId?: string | null;
/** Rendered inside the Settings dialog: a run opens inline in local state
* instead of navigating the host app to a top-level route (which would
* unmount the dialog). */
readonly embedded?: boolean;
}) {
const navigate = useNavigate();
const { mode, runId } = parseInsightsPath(path);
const [embeddedRunId, setEmbeddedRunId] = useState<string | null>(null);

if (runs.kind === "unauthenticated") {
return (
<div className="page-frame">
<StageTopBar crumbs={[{ label: "Runs" }]} />
<StageTopBar crumbs={[{ label: "Runs" }]} hideSidebarToggle={embedded} />
<PageShell width="full" className="page-fill">
<SignedOutNotice />
</PageShell>
Expand All @@ -401,12 +435,29 @@ export function InsightsPage({
return <InsightsRunDetail run={run} tenantId={tenantId} />;
}

if (embedded && embeddedRunId !== null) {
const run = runsData.find((r) => r.id === embeddedRunId) ?? null;
return (
<InsightsRunDetail
run={run}
tenantId={tenantId}
embedded
onBack={() => setEmbeddedRunId(null)}
/>
);
}

return (
<InsightsRunsHistory
runs={runsData}
loading={runs.kind === "loading"}
nextCursor={runsNextCursor}
onOpenRun={(id) => navigate(`${INSIGHTS_RUNS_PATH}/${encodeURIComponent(id)}`)}
onOpenRun={
embedded
? (id) => setEmbeddedRunId(id)
: (id) => navigate(`${INSIGHTS_RUNS_PATH}/${encodeURIComponent(id)}`)
}
embedded={embedded}
/>
);
}
Expand Down Expand Up @@ -478,7 +529,13 @@ function InsightsLandingRedirect({ benchTenantId }: { readonly benchTenantId: st
return <Redirect to={to} from={INSIGHTS_PATH_PREFIX} navigate={navigate} />;
}

export function InsightsRoute({ path }: { readonly path?: string }) {
export function InsightsRoute({
path,
embedded = false,
}: {
readonly path?: string;
readonly embedded?: boolean;
}) {
const { selectedTenantId: benchTenantId } = useBench();
// A run opened from a workbench's Insights carries `from=`, so it reads
// that workbench's tenant rather than the bench.
Expand Down Expand Up @@ -515,7 +572,14 @@ export function InsightsRoute({ path }: { readonly path?: string }) {
nextCursor: string | null;
}> = selectedTenantId === null ? { kind: "ready", data: { data: [], nextCursor: null } } : runs;

return <InsightsPage path={currentPath} runs={runsForPage} tenantId={selectedTenantId} />;
return (
<InsightsPage
path={currentPath}
runs={runsForPage}
tenantId={selectedTenantId}
embedded={embedded}
/>
);
}

/** Resolves the workbench-scoped route's own workbench list. */
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/settings-groups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { insertEveryoneSections, resolveSettingsSectionGroups } from "@/settings
import type { SettingsSectionGroup, TenancyAccess } from "@/settings";

import { InsightsRoute } from "./pages/insights-page";
import { SETTINGS_INSIGHTS_PATH } from "./settings-insights-path";

export function resolveAppSettingsSectionGroups(
access: TenancyAccess,
Expand All @@ -16,7 +17,7 @@ export function resolveAppSettingsSectionGroups(
id: "insights",
title: "Insights",
icon: ChartBar,
render: () => <InsightsRoute />,
render: () => <InsightsRoute path={SETTINGS_INSIGHTS_PATH} embedded />,
},
]);
}
51 changes: 51 additions & 0 deletions apps/web/src/settings-insights-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";

import { parseInsightsPath } from "./insights-path";
import { SETTINGS_INSIGHTS_PATH } from "./settings-insights-path";
import type { TenancyAccess } from "./settings/access";

describe("settings-hosted insights path", () => {
test("renders the runs history inline instead of landing-redirecting away", () => {
expect(parseInsightsPath(SETTINGS_INSIGHTS_PATH).mode).toBe("runs");
});

test("the settings URL itself is an insights landing page, so the dialog must never hand it to the route", () => {
expect(parseInsightsPath("/settings/insights").mode).toBe("landing");
});

test("the embedded insights section gets an explicit non-landing path, never the host URL fallback", async () => {
// The section's module chain reads `window.location` at import time, and
// bun's runner has no DOM — stub just enough of it to evaluate, then
// inspect the returned element's props instead of mounting.
Object.assign(globalThis, {
window: {
location: { pathname: "/settings/insights" },
history: { replaceState: () => {} },
addEventListener: () => {},
removeEventListener: () => {},
},
});
const { resolveAppSettingsSectionGroups } = await import("./settings-groups");
const access: TenancyAccess = {
people: "allowed",
roles: "allowed",
grants: "allowed",
credentials: "allowed",
};
const sections = resolveAppSettingsSectionGroups(access).flatMap((group) => group.sections);
const insights = sections.find((section) => section.id === "insights");
if (insights === undefined) throw new Error("insights section missing from settings groups");
const { path, embedded } = insights.render({ tenantId: null, principalId: null }).props as {
readonly path?: unknown;
readonly embedded?: unknown;
};
if (typeof path !== "string") {
throw new Error("insights section must hand the embedded route an explicit path");
}
expect(parseInsightsPath(path).mode).not.toBe("landing");
// The embedded route must suppress its full-page chrome (crumb links,
// run-row top-level navigation, sidebar toggle), or clicking any of them
// navigates the host app out from under the dialog.
expect(embedded).toBe(true);
});
});
9 changes: 9 additions & 0 deletions apps/web/src/settings-insights-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// The path the Settings dialog hands its embedded Insights route. The route
// falls back to the browser URL when it gets no path, and the dialog's own
// `/settings/insights` parses as an Insights landing page — landing renders a
// redirect to a top-level route, which unmounts the dialog. Hand it the runs
// history instead: that mode renders inline and never navigates away.

import { INSIGHTS_RUNS_PATH } from "./path-ids";

export const SETTINGS_INSIGHTS_PATH = INSIGHTS_RUNS_PATH;
6 changes: 5 additions & 1 deletion apps/web/src/shell/stage-top-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function StageTopBar({
chip,
filter,
actions,
hideSidebarToggle = false,
}: {
/** The page's title trail: parents first, the page itself last. */
readonly crumbs: readonly StageCrumb[];
Expand All @@ -33,11 +34,14 @@ export function StageTopBar({
readonly filter?: StageSearchProps;
/** The primary-action slot: the buttons and inputs this page owns. */
readonly actions?: ReactNode;
/** Views embedded in a dialog (Settings' Insights section) own no sidebar,
* so the host shell's toggle would flip the page behind the dialog. */
readonly hideSidebarToggle?: boolean;
}) {
const hasSubtitle = subtitle !== undefined && subtitle !== null;
return (
<header className="stage-top-bar" data-testid="stage-top-bar">
<StageSidebarToggle />
{hideSidebarToggle ? null : <StageSidebarToggle />}
<div className="stage-top-bar-title">
<PageCrumbs crumbs={crumbs} />
</div>
Expand Down
64 changes: 64 additions & 0 deletions e2e/browser/settings-dialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// The Settings dialog keeps its Insights section inline: clicking the
// section leaves the dialog mounted with the runs panel (no top-level
// navigation unmounting the dialog), and Close dismisses it.
import { expect, test } from "bun:test";
import { bootBrowserApp, browserGate } from "../lib/browser";
import { clickText, runFirstRunFlow, STEP_TIMEOUT, waitForText } from "../lib/first-run";

const describeBrowser = browserGate(import.meta.path);

// Reads the dialog's embedded chrome: any crumb link out to /insights or a
// host sidebar toggle would navigate (or flip) the page behind the dialog.
const EMBEDDED_CHROME_SCRIPT = `(() => {
const dialog = document.querySelector(".settings-dialog");
if (dialog === null) return null;
return {
crumbLink: dialog.querySelector('a[href^="/insights"]')?.getAttribute("href") ?? null,
sidebarToggle: dialog.querySelector(".stage-sidebar-toggle") !== null,
};
})()`;

describeBrowser("settings dialog", () => {
const app = bootBrowserApp();

test("the Insights section renders runs inline and Close dismisses", async () => {
const { page, errors } = await app().newPage();
try {
await runFirstRunFlow(page, app().origin);

// Open Settings over the workbench page; a cold load falls back to a
// backdrop page and mounts the dialog on top.
await page.goto(`${app().origin}/settings`, { waitUntil: "networkidle0" });
await page.waitForSelector(".settings-dialog", { timeout: STEP_TIMEOUT });
await waitForText(page, "Settings");

// Click the Insights section: the section button must not close the
// dialog (the regression), so the dialog stays mounted at /settings
// with the runs panel rendered inline.
await clickText(page, ".settings-nav button", "Insights");
await page.waitForFunction(
`location.pathname.startsWith("/settings") && document.querySelector(".settings-dialog") !== null && document.body.innerText.includes("Run history")`,
{ timeout: STEP_TIMEOUT },
);

const chrome = (await page.evaluate(EMBEDDED_CHROME_SCRIPT)) as {
crumbLink: string | null;
sidebarToggle: boolean;
} | null;
expect(chrome).toEqual({ crumbLink: null, sidebarToggle: false });

// Close dismisses the dialog back to the page behind it.
await page.click('.settings-dialog [aria-label="Close"]');
await page.waitForFunction(`document.querySelector(".settings-dialog") === null`, {
timeout: STEP_TIMEOUT,
});
expect(new URL(page.url()).pathname.startsWith("/settings")).toBe(false);
} catch (cause) {
process.stderr.write(
`settings-dialog failed at ${page.url()}\nconsole: ${errors.join(" | ")}\n`,
);
throw cause;
}
await page.close();
}, 240_000);
});
Loading