feat(web): add Ask Fabro sidebar and settings panels (#346)

## Summary

Adds the prototype Ask Fabro docked sidebar to run detail pages behind
`?ask=1`, with app-shell layout coordination so opening the sidebar
shifts content instead of covering it. The branch also improves settings
visibility with active run concurrency on Resources and a Project
Management integrations placeholder.

## Changes

- Add a shared Ask Fabro layout context so the app shell can inset main
content by the docked sidebar width.
- Gate the run detail Ask Fabro button and sidebar behind `?ask=1`,
keeping the bottom steer/interview bar aligned while the sidebar is
open.
- Poll system info on the Resources page to show active runs against the
scheduler limit.
- Add a Project Management panel with Linear marked as coming soon.

## Verification

Not run; PR opened from the existing branch without changing code.

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 (unknown context, medium reasoning) via
[Codex](https://openai.com/codex)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-21 19:36:19 -04:00 • committed by GitHub
parent 2b168b4588
commit 4e4ab091d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 199 additions and 17 deletions

View file

@ -24,7 +24,7 @@ const EMPTY_CHAT: Chat = {
pendingResponse: false,
};
const SIDEBAR_WIDTH = 420;
export const SIDEBAR_WIDTH = 420;
/**
* Right-docked "Ask Fabro" assistant panel. An animated-width column that

View file

@ -20,6 +20,7 @@ import {
import { Link, Outlet, useLocation, useMatches } from "react-router";
import { ErrorState } from "../components/state";
import { ToastProvider } from "../components/toast";
import { AskFabroLayoutProvider, useAskFabroLayout } from "../lib/ask-fabro-layout";
import { DemoModeProvider } from "../lib/demo-mode";
import { useToggleDemoMode } from "../lib/mutations";
import { useAuthMe } from "../lib/queries";
@ -94,6 +95,7 @@ export default function AppShell() {
return (
<DemoModeProvider value={demoMode}>
<ToastProvider>
<AskFabroLayoutProvider>
<div
className={classNames(
"isolate",
@ -300,18 +302,43 @@ export default function AppShell() {
</div>
</header>
)}
<main className={fullHeight ? "min-h-0 flex-1" : undefined}>
<div
className={classNames(
`mx-auto ${maxWidth} px-4 py-6 sm:px-6 lg:px-8`,
fullHeight && "box-border flex h-full min-h-0 flex-col",
)}
>
<Outlet />
</div>
</main>
<ShellMain fullHeight={fullHeight} maxWidth={maxWidth} />
</div>
</AskFabroLayoutProvider>
</ToastProvider>
</DemoModeProvider>
);
}
/**
* The scrollable content region. Inset on the right by the docked Ask Fabro
* sidebar's width so opening the sidebar shifts the page content left rather
* than covering it.
*/
function ShellMain({
fullHeight,
maxWidth,
}: {
fullHeight: boolean;
maxWidth: string;
}) {
const { sidebarWidth } = useAskFabroLayout();
return (
<main
className={classNames(
"transition-[padding] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]",
fullHeight && "min-h-0 flex-1",
)}
style={{ paddingRight: sidebarWidth }}
>
<div
className={classNames(
`mx-auto ${maxWidth} px-4 py-6 sm:px-6 lg:px-8`,
fullHeight && "box-border flex h-full min-h-0 flex-col",
)}
>
<Outlet />
</div>
</main>
);
}

View file

@ -0,0 +1,41 @@
import { createContext, useContext, useMemo, useState } from "react";
/**
* Layout coordination for the docked "Ask Fabro" sidebar. The run detail page
* owns the open/closed state and publishes the sidebar's current width here;
* the app shell reads it and insets `<main>` by that amount so the page
* content shifts left instead of being covered by the fixed sidebar.
*/
interface AskFabroLayout {
/** Width in px the docked sidebar currently occupies; 0 when closed. */
sidebarWidth: number;
setSidebarWidth: (width: number) => void;
}
const NOOP_LAYOUT: AskFabroLayout = {
sidebarWidth: 0,
setSidebarWidth: () => {},
};
const AskFabroLayoutContext = createContext<AskFabroLayout>(NOOP_LAYOUT);
export function AskFabroLayoutProvider({
children,
}: {
children: React.ReactNode;
}) {
const [sidebarWidth, setSidebarWidth] = useState(0);
const value = useMemo(
() => ({ sidebarWidth, setSidebarWidth }),
[sidebarWidth],
);
return (
<AskFabroLayoutContext.Provider value={value}>
{children}
</AskFabroLayoutContext.Provider>
);
}
export function useAskFabroLayout(): AskFabroLayout {
return useContext(AskFabroLayoutContext);
}

View file

@ -89,11 +89,11 @@ export function useAuthSessions() {
);
}
export function useSystemInfo() {
export function useSystemInfo(refreshInterval?: number) {
return useSWR<SystemInfoResponse>(
queryKeys.system.info(),
() => apiData(() => systemApi.getSystemInfo()),
immutableOptions,
refreshInterval ? { ...immutableOptions, refreshInterval } : immutableOptions,
);
}

View file

@ -24,6 +24,10 @@ mock.module("../lib/queries", () => ({
useRunQuestions: () => ({
data: currentQuestions,
}),
useRunPullRequest: () => ({
data: null,
isLoading: false,
}),
useRunState: () => ({
data: currentRunState,
}),

View file

@ -14,10 +14,21 @@ import {
FolderIcon,
RectangleStackIcon,
SignalIcon,
SparklesIcon,
} from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router";
import {
Link,
Outlet,
useLocation,
useMatches,
useNavigate,
useSearchParams,
} from "react-router";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import AskFabroSidebar, {
SIDEBAR_WIDTH,
} from "../components/chats/ask-fabro-sidebar";
import { EditableRunTitle } from "../components/editable-run-title";
import { GitPullRequestIcon } from "../components/icons";
import { InterviewDock } from "../components/interview-dock";
@ -37,6 +48,7 @@ import type {
RunLifecycle,
WorkflowRef,
} from "@qltysh/fabro-api-client";
import { useAskFabroLayout } from "../lib/ask-fabro-layout";
import { useDemoMode } from "../lib/demo-mode";
import { useSWRConfig } from "swr";
import {
@ -348,6 +360,13 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const questionsQuery = useRunQuestions(params.id, isBlocked);
const pendingQuestions = questionsQuery.data ?? [];
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
// The "Ask Fabro" assistant is gated behind ?ask=1 while the feature is in
// prototype: the trigger button and the docked sidebar only render then.
const askEnabled = searchParams.get("ask") === "1";
const [askOpen, setAskOpen] = useState(false);
const sidebarWidth = askEnabled && askOpen ? SIDEBAR_WIDTH : 0;
const { setSidebarWidth } = useAskFabroLayout();
const matches = useMatches();
const basePath = `/runs/${params.id}`;
const previewMutation = usePreviewRun(params.id);
@ -380,6 +399,13 @@ export default function RunDetail({ params }: { params: { id: string } }) {
useRunEvents(params.id);
useRunToasts(params.id);
// Publish the docked sidebar's width so the app shell insets `<main>` and
// the page content shifts left while the sidebar is open.
useEffect(() => {
setSidebarWidth(sidebarWidth);
return () => setSidebarWidth(0);
}, [sidebarWidth, setSidebarWidth]);
useEffect(() => {
if (previewMutation.data?.intent === "preview") {
window.open(previewMutation.data.url, "_blank");
@ -605,6 +631,21 @@ export default function RunDetail({ params }: { params: { id: string } }) {
cancelPending={cancelPending}
onCancel={() => void cancelMutation.trigger()}
/>
{askEnabled && (
<button
type="button"
onClick={() => setAskOpen(true)}
disabled={askOpen}
className={classNames(
SECONDARY_BUTTON_CLASS,
"disabled:cursor-not-allowed disabled:opacity-60",
)}
>
<SparklesIcon className="size-4 text-teal-300" aria-hidden="true" />
Ask Fabro
</button>
)}
</div>
<ConfirmDialog
@ -669,13 +710,24 @@ export default function RunDetail({ params }: { params: { id: string } }) {
<Outlet />
</div>
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-page">
<div
className="fixed bottom-0 left-0 z-30 border-t border-line bg-page transition-[right] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]"
style={{ right: sidebarWidth }}
>
{hasPendingQuestions ? (
<InterviewDock runId={params.id} questions={pendingQuestions} />
) : (
<SteerBar ref={steerBarRef} runId={params.id} />
)}
</div>
{askEnabled && (
// Docked below the top nav (h-16) and above the steer bar (z-30); the
// sidebar animates its own width, so the wrapper collapses when closed.
<div className="fixed top-16 right-0 bottom-0 z-40">
<AskFabroSidebar isOpen={askOpen} onClose={() => setAskOpen(false)} />
</div>
)}
</div>
);
}

View file

@ -38,10 +38,21 @@ export default function SettingsIntegrations() {
<PanelSkeleton />
</>
)}
<ProjectManagementPanel />
</div>
);
}
function ProjectManagementPanel() {
return (
<Panel title="Project Management">
<Row title="Linear" help="Sync runs with Linear issues and projects.">
<span className="text-sm text-fg-muted">Coming Soon</span>
</Row>
</Panel>
);
}
function GithubPanel({ settings }: { settings: ServerSettings }) {
const { github } = settings.server.integrations;
return (

View file

@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import type {
ServerSettings,
SystemCpuResources,
SystemDiskResources,
SystemInfoResponse,
SystemMemoryResources,
SystemResourcesResponse,
} from "@qltysh/fabro-api-client";
@ -9,9 +11,13 @@ import TestRenderer, { act } from "react-test-renderer";
import { setupReactTestEnv } from "../lib/test-utils";
let systemResources: SystemResourcesResponse | undefined;
let systemInfo: SystemInfoResponse | undefined;
let serverSettings: ServerSettings | undefined;
let teardownReactTestEnv: (() => void) | undefined;
mock.module("../lib/queries", () => ({
useServerSettings: () => ({ data: serverSettings }),
useSystemInfo: () => ({ data: systemInfo }),
useSystemResources: () => ({ data: systemResources }),
}));
@ -89,9 +95,20 @@ function sampleResources(overrides: ResourceOverrides = {}): SystemResourcesResp
};
}
function sampleServerSettings(maxConcurrentRuns = 8): ServerSettings {
return {
server: {
scheduler: { max_concurrent_runs: maxConcurrentRuns },
},
features: { session_sandboxes: false },
} as unknown as ServerSettings;
}
describe("SettingsResources route", () => {
beforeEach(() => {
teardownReactTestEnv = setupReactTestEnv();
systemInfo = { runs: { active: 3, total: 12 } };
serverSettings = sampleServerSettings();
});
afterEach(() => {
@ -101,6 +118,8 @@ describe("SettingsResources route", () => {
}
});
systemResources = undefined;
systemInfo = undefined;
serverSettings = undefined;
teardownReactTestEnv?.();
teardownReactTestEnv = undefined;
});
@ -115,6 +134,7 @@ describe("SettingsResources route", () => {
expect(text).toContain("5s");
expect(text).toContain("3 GiB");
expect(text).toContain("8 GiB");
expect(text).toContain("3 / 8 active");
});
test("shows CPU warmup state while usage is null", () => {

View file

@ -5,7 +5,7 @@ import type {
SystemResourcesResponse,
} from "@qltysh/fabro-api-client";
import { formatBytesAsMemory, formatDurationMs } from "../lib/format";
import { useSystemResources } from "../lib/queries";
import { useServerSettings, useSystemInfo, useSystemResources } from "../lib/queries";
import {
Badge,
Mono,
@ -21,7 +21,7 @@ export function meta() {
}
const DESCRIPTION =
"Server-visible CPU, memory, and storage filesystem usage for this Fabro process.";
"Server-visible run concurrency, CPU, memory, and storage filesystem usage for this Fabro process.";
export default function SettingsResources() {
const resourcesQuery = useSystemResources();
@ -30,6 +30,7 @@ export default function SettingsResources() {
return (
<div className="space-y-6">
<SettingsPageIntro description={DESCRIPTION} />
<RunsPanel />
{resources ? (
<>
<CpuPanel cpu={resources.cpu} />
@ -48,6 +49,32 @@ export default function SettingsResources() {
);
}
function RunsPanel() {
const infoQuery = useSystemInfo(5_000);
const settingsQuery = useServerSettings();
const info = infoQuery.data;
const settings = settingsQuery.data;
if (!info || !settings) {
return <PanelSkeleton />;
}
const active = info.runs?.active ?? 0;
const max = settings.server.scheduler.max_concurrent_runs;
const percent = max > 0 ? (active / max) * 100 : null;
return (
<Panel title="Runs">
<Row
title="Active"
help="Runs currently queued or executing against the scheduler ceiling."
>
<UsageMeter percent={percent} label={`${active} / ${max} active`} />
</Row>
</Panel>
);
}
function CpuPanel({ cpu }: { cpu: SystemCpuResources }) {
if (!cpu.supported) {
return (