mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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. --- [](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:
parent
2b168b4588
commit
4e4ab091d4
9 changed files with 199 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
41
apps/fabro-web/app/lib/ask-fabro-layout.tsx
Normal file
41
apps/fabro-web/app/lib/ask-fabro-layout.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ mock.module("../lib/queries", () => ({
|
|||
useRunQuestions: () => ({
|
||||
data: currentQuestions,
|
||||
}),
|
||||
useRunPullRequest: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
useRunState: () => ({
|
||||
data: currentRunState,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue