diff --git a/apps/web/src/pages/bench-insights.css b/apps/web/src/pages/bench-insights.css index 923dce0d5..e163cac32 100644 --- a/apps/web/src/pages/bench-insights.css +++ b/apps/web/src/pages/bench-insights.css @@ -1,14 +1,14 @@ -.bi-head { - margin-bottom: 1rem; +/* Card and table labels read as sentence case, like the drawer's tabs. */ +.page-layout .insights-panel h3 { + font-size: 0.9rem; + letter-spacing: 0; + text-transform: none; + color: var(--ink); } -.bi-head h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; -} -.bi-head p { - margin: 0.25rem 0 0; - color: var(--muted-foreground); +.page-layout .bi-table th { + font-size: 0.78rem; + letter-spacing: 0; + text-transform: none; } .bi-other { background: var(--muted-foreground); diff --git a/apps/web/src/pages/bench-insights.tsx b/apps/web/src/pages/bench-insights.tsx index 0c9386bbe..58445913c 100644 --- a/apps/web/src/pages/bench-insights.tsx +++ b/apps/web/src/pages/bench-insights.tsx @@ -1,7 +1,7 @@ // The bench-scoped Insights dashboard: every number is computed from the // stock `GET /workflows/runs` listing of the bench's own tenant. -import { Badge, PageShell, RichEmptyState, Skeleton, RUN_STATUS_TONE } from "@corbits/react-ui"; +import { Badge, RichEmptyState, Skeleton, RUN_STATUS_TONE } from "@corbits/react-ui"; import { useState, type ReactNode } from "react"; import { useAPIQuery } from "../api"; @@ -17,6 +17,7 @@ import { } from "../insights-stats"; import { formatWhen } from "./insights-page"; import { useFromBench } from "../shell/page-crumbs"; +import { PageLayout } from "../shell/page-layout"; import { StageTopBar } from "../shell/stage-top-bar"; import { workbenchPath } from "../workbench-path"; @@ -207,13 +208,9 @@ export function BenchInsights({
- -
-

Insights

-

What happened in {title}.

-
+ {body} -
+
); diff --git a/apps/web/src/pages/routine-detail-page.tsx b/apps/web/src/pages/routine-detail-page.tsx index 361d688f7..e3cdc37e3 100644 --- a/apps/web/src/pages/routine-detail-page.tsx +++ b/apps/web/src/pages/routine-detail-page.tsx @@ -1,13 +1,14 @@ import { PageShell, RichEmptyState, RunNowButton, Skeleton } from "@corbits/react-ui"; import { type } from "arktype"; import { useState } from "react"; -import type { ReactNode } from "react"; +import { Clock } from "@/lib/icons"; import { useAPIQuery } from "../api"; import { useGlobalRoutines, useRoutineActions } from "../global-routines"; import type { GlobalRoutineRow } from "../global-routines"; -import { Link } from "../navigation"; +import { useNavigate } from "../navigation"; import { WORKFLOWS_PATH_PREFIX } from "../path-ids"; +import { PageLayout } from "../shell/page-layout"; import { StageTopBar } from "../shell/stage-top-bar"; import { RoutinePill, formatWhen, routineState, useRoutineRuns } from "./routine-ui"; import type { RunRow } from "./routine-ui"; @@ -140,25 +141,28 @@ function RoutineRunsSection({ function RoutineNotice({ title, description, - children, }: { readonly title: string; readonly description: string; - readonly children?: ReactNode; }) { + const navigate = useNavigate(); return (
- -

{description}

- {children ?? ( -

- Back to Workflows -

- )} -
+
+ + } + title="Workflow unavailable" + description={description} + actions={[ + { label: "Back to Workflows", onClick: () => navigate(WORKFLOWS_PATH_PREFIX) }, + ]} + /> + +
); } diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index be3c25550..eb1b8744b 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -2,7 +2,7 @@ // paused (`stopped`) ones. Run-now is the only write. import { EmptyState, RichEmptyState, RunNowButton } from "@corbits/react-ui"; import { cronSentence } from "@corbits/workflows/client"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { Clock } from "@/lib/icons"; import { useGlobalRoutines, useRoutineActions } from "../global-routines"; @@ -57,6 +57,17 @@ function RoutineListRow({ ); } +function WorkflowsLayout({ children }: { readonly children: ReactNode }) { + return ( + + {children} + + ); +} + export function GlobalRoutinesList({ rows, onRunNow, @@ -68,11 +79,13 @@ export function GlobalRoutinesList({ const fromId = useFromBench(); if (rows.length === 0) { return ( - } - title="No workflows yet" - description="A deployed workflow shows up here. Run it now from its row." - /> + + } + title="No workflows yet" + description="A deployed workflow shows up here. Run it now from its row." + /> + ); } const needle = query.trim().toLowerCase(); @@ -82,10 +95,7 @@ export function GlobalRoutinesList({ .includes(needle), ); return ( - + ))} - + ); } @@ -117,17 +127,17 @@ export function RoutinesRoute() {
{routinesQuery.kind === "loading" ? ( -
+ } title="Loading workflows…" /> -
+ ) : routinesQuery.kind === "error" ? ( -
+ } title="Couldn't load workflows" description={routinesQuery.message} /> -
+ ) : ( actions.runNow(row)} /> )} diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index 769acddf5..40362a19d 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -14,6 +14,7 @@ import { useNavigate } from "../navigation"; import { saveArtifactContent } from "./library-artifacts"; import { APP_ROUTES, matchesRoute } from "../routes"; import type { SessionUser } from "../session"; +import { isWorkbenchPath } from "../workbench-path"; import { StageTopBar } from "./stage-top-bar"; import { useCanvasColumnArtifact, @@ -26,6 +27,7 @@ import { useToggleCanvasFocus, } from "./canvas-availability"; import { Sidebar } from "./sidebar"; +import { isNarrow, SidebarToggleContext, useIsNarrow } from "./sidebar-toggle"; import { ShellContextMenu } from "./context-menu/shell-context-menu"; import { FirstRunTour } from "./first-run-tour"; import "./shell-narrow.css"; @@ -126,6 +128,16 @@ export function AppShell({ // Keyed by path so any navigation closes the phone-width sidebar. const [sidebarOpenFor, setSidebarOpenFor] = useState(null); const sidebarOpen = sidebarOpenFor === path; + // Desktop collapse. The chat and home screens carry no `StageTopBar`, so + // they always show the sidebar rather than leave no way back. + const [collapsed, setCollapsed] = useState(false); + const narrow = useIsNarrow(); + const collapsible = path !== "/" && !isWorkbenchPath(path); + const sidebarCollapsed = collapsed && collapsible; + const toggleSidebar = () => { + if (isNarrow()) setSidebarOpenFor(sidebarOpen ? null : path); + else setCollapsed(!collapsed); + }; useEffect(() => { if (!sidebarOpen) return; const onKey = (event: KeyboardEvent) => { @@ -146,61 +158,69 @@ export function AppShell({ : { tone: "ok" as const, label: "All caught up" }; return ( -
- +
setSidebarOpenFor(null)} - aria-hidden="true" - /> - -
- -
- {routeHasNoStageTopBar(path) ? ( - + +
setSidebarOpenFor(null)} + aria-hidden="true" + /> + +
+ +
+ {routeHasNoStageTopBar(path) ? ( + + ) : null} + + +
+ } + > + {children} + +
+
+ {canvasAllowed ? ( + + - ) : null} - - -
- } - > - {children} -
+ ) : null} + +
- {canvasAllowed ? ( - - - - ) : null} - - -
+ ); } diff --git a/apps/web/src/shell/page-crumbs.tsx b/apps/web/src/shell/page-crumbs.tsx index 6b9267e99..07e4ec98f 100644 --- a/apps/web/src/shell/page-crumbs.tsx +++ b/apps/web/src/shell/page-crumbs.tsx @@ -57,6 +57,15 @@ export function usePageCrumbs(crumbs: readonly PageCrumb[]): { readonly back: { readonly href: string; readonly name: string } | null; } { const from = useFromWorkbench(); + const fromId = useFromBench(); + // A bare one-level page reads "Workbench / "; `?from=` swaps the + // root for the bench itself. + if (fromId === null) { + return { + crumbs: crumbs.length === 1 ? [{ label: "Workbench" }, ...crumbs] : crumbs, + back: null, + }; + } if (from === null || from.name === null) return { crumbs, back: null }; const href = workbenchPath(from.id); return { diff --git a/apps/web/src/shell/shell-narrow.css b/apps/web/src/shell/shell-narrow.css index 5d72f5d7c..a4268afa2 100644 --- a/apps/web/src/shell/shell-narrow.css +++ b/apps/web/src/shell/shell-narrow.css @@ -4,14 +4,32 @@ display: none; } +/* Desktop: the toggle sits in the page's top bar, centred with the title. */ +.stage-sidebar-toggle { + flex: none; + align-self: center; + margin-left: -6px; +} + +.shell-frame[data-sidebar-collapsed="true"] .shell-sidebar { + display: none; +} + +.shell-frame[data-sidebar-collapsed="true"] .shell-main { + margin-left: 8px; +} + @media (max-width: 860px) { .shell-sidebar-toggle { display: inline-flex; position: fixed; - top: 0.5rem; + top: 10px; left: 0.5rem; z-index: 30; } + .stage-sidebar-toggle { + display: none; + } .shell-frame .stage-top-bar { padding-left: 3rem; } diff --git a/apps/web/src/shell/sidebar-toggle.tsx b/apps/web/src/shell/sidebar-toggle.tsx new file mode 100644 index 000000000..7a5939da4 --- /dev/null +++ b/apps/web/src/shell/sidebar-toggle.tsx @@ -0,0 +1,48 @@ +// One handler for the sidebar toggle: `AppShell` owns the state and the +// phone-width button; the desktop button lives in each page's `StageTopBar`. + +import { createContext, useContext, useSyncExternalStore } from "react"; +import { Button } from "@corbits/react-ui"; +import { PanelLeft } from "@/lib/icons"; + +export type SidebarToggle = { + readonly expanded: boolean; + readonly toggle: () => void; +}; + +export const SidebarToggleContext = createContext(null); + +// Mirrors the `max-width: 860px` breakpoint in shell-narrow.css. +const NARROW_QUERY = "(max-width: 860px)"; + +export function subscribeNarrow(onChange: () => void): () => void { + const query = window.matchMedia(NARROW_QUERY); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); +} + +export function isNarrow(): boolean { + return window.matchMedia(NARROW_QUERY).matches; +} + +export function useIsNarrow(): boolean { + return useSyncExternalStore(subscribeNarrow, isNarrow); +} + +/** The desktop sidebar toggle; phone width uses the shell's fixed button. */ +export function StageSidebarToggle() { + const sidebar = useContext(SidebarToggleContext); + if (sidebar === null) return null; + return ( + + ); +} diff --git a/apps/web/src/shell/stage-top-bar.tsx b/apps/web/src/shell/stage-top-bar.tsx index 3bee982a0..754ece23c 100644 --- a/apps/web/src/shell/stage-top-bar.tsx +++ b/apps/web/src/shell/stage-top-bar.tsx @@ -9,6 +9,7 @@ import type { ReactNode } from "react"; import { PageCrumbs, type PageCrumb } from "./page-crumbs"; import { Chip, type ChipTone } from "./chip"; +import { StageSidebarToggle } from "./sidebar-toggle"; import { StageSearch, type StageSearchProps } from "./stage-search"; export type StageCrumb = PageCrumb; @@ -36,6 +37,7 @@ export function StageTopBar({ const hasSubtitle = subtitle !== undefined && subtitle !== null; return (
+