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 (