diff --git a/apps/fabro-web/app/components/chats/ask-fabro-sidebar.tsx b/apps/fabro-web/app/components/chats/ask-fabro-sidebar.tsx index 33088b3c8..6e239a3ae 100644 --- a/apps/fabro-web/app/components/chats/ask-fabro-sidebar.tsx +++ b/apps/fabro-web/app/components/chats/ask-fabro-sidebar.tsx @@ -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 diff --git a/apps/fabro-web/app/layouts/app-shell.tsx b/apps/fabro-web/app/layouts/app-shell.tsx index 7c2a86a7f..de9ff9832 100644 --- a/apps/fabro-web/app/layouts/app-shell.tsx +++ b/apps/fabro-web/app/layouts/app-shell.tsx @@ -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 ( +
)} -
-
- -
-
+
+
); } + +/** + * 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 ( +
+
+ +
+
+ ); +} diff --git a/apps/fabro-web/app/lib/ask-fabro-layout.tsx b/apps/fabro-web/app/lib/ask-fabro-layout.tsx new file mode 100644 index 000000000..82593ae35 --- /dev/null +++ b/apps/fabro-web/app/lib/ask-fabro-layout.tsx @@ -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 `
` 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(NOOP_LAYOUT); + +export function AskFabroLayoutProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [sidebarWidth, setSidebarWidth] = useState(0); + const value = useMemo( + () => ({ sidebarWidth, setSidebarWidth }), + [sidebarWidth], + ); + return ( + + {children} + + ); +} + +export function useAskFabroLayout(): AskFabroLayout { + return useContext(AskFabroLayoutContext); +} diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index 0f768e7fb..77a4f3468 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -89,11 +89,11 @@ export function useAuthSessions() { ); } -export function useSystemInfo() { +export function useSystemInfo(refreshInterval?: number) { return useSWR( queryKeys.system.info(), () => apiData(() => systemApi.getSystemInfo()), - immutableOptions, + refreshInterval ? { ...immutableOptions, refreshInterval } : immutableOptions, ); } diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index c42c25910..6a6f72d8f 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -24,6 +24,10 @@ mock.module("../lib/queries", () => ({ useRunQuestions: () => ({ data: currentQuestions, }), + useRunPullRequest: () => ({ + data: null, + isLoading: false, + }), useRunState: () => ({ data: currentRunState, }), diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 750ff36e8..f51735f40 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -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 `
` 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 && ( + + )} -
+
{hasPendingQuestions ? ( ) : ( )}
+ + {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. +
+ setAskOpen(false)} /> +
+ )}
); } diff --git a/apps/fabro-web/app/routes/settings-integrations.tsx b/apps/fabro-web/app/routes/settings-integrations.tsx index 64140f1ef..cf17cfd9d 100644 --- a/apps/fabro-web/app/routes/settings-integrations.tsx +++ b/apps/fabro-web/app/routes/settings-integrations.tsx @@ -38,10 +38,21 @@ export default function SettingsIntegrations() { )} + ); } +function ProjectManagementPanel() { + return ( + + + Coming Soon + + + ); +} + function GithubPanel({ settings }: { settings: ServerSettings }) { const { github } = settings.server.integrations; return ( diff --git a/apps/fabro-web/app/routes/settings-resources.test.tsx b/apps/fabro-web/app/routes/settings-resources.test.tsx index e5aca9f72..6176eb9e1 100644 --- a/apps/fabro-web/app/routes/settings-resources.test.tsx +++ b/apps/fabro-web/app/routes/settings-resources.test.tsx @@ -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", () => { diff --git a/apps/fabro-web/app/routes/settings-resources.tsx b/apps/fabro-web/app/routes/settings-resources.tsx index 2506ad739..043b9d360 100644 --- a/apps/fabro-web/app/routes/settings-resources.tsx +++ b/apps/fabro-web/app/routes/settings-resources.tsx @@ -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 (
+ {resources ? ( <> @@ -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 ; + } + + const active = info.runs?.active ?? 0; + const max = settings.server.scheduler.max_concurrent_runs; + const percent = max > 0 ? (active / max) * 100 : null; + + return ( + + + + + + ); +} + function CpuPanel({ cpu }: { cpu: SystemCpuResources }) { if (!cpu.supported) { return (