From 4d13c36b732389991f69c304a161c8e68dc5dd15 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:58:59 +0100 Subject: [PATCH 1/3] feat(shell): give observers their own sidebar and command palette An observer follows the product without operating it, so the shell shows them the pages that answer whether it works and how it is doing: incidents with uptime and SLOs, errors, status pages, web analytics, users, replays, dashboards, releases and cost. The explorers, infrastructure, alert setup, review, security, Sources, Settings and the Connect button are left out. The command palette lists the same pages plus dashboards and the app actions. Other pages still open from a link: nothing is redirected, and the API refuses any change. The shell lab gains a viewer toggle to draw the sidebar for an observer. --- apps/web/src/lab/shell-frame.ts | 4 +- apps/web/src/lab/shell-states-lab.ts | 30 ++++++- apps/web/src/shell/model.ts | 9 +- apps/web/src/shell/nav.test.ts | 35 +++++++- apps/web/src/shell/nav.ts | 82 ++++++++++++++++++- apps/web/src/shell/palette/entries.ts | 53 +++++++----- .../shell/palette/global-shortcuts.test.ts | 7 +- apps/web/src/shell/palette/model.ts | 3 +- apps/web/src/shell/palette/palette.test.ts | 33 +++++++- apps/web/src/shell/palette/update.ts | 37 ++++++--- apps/web/src/shell/palette/view.ts | 11 ++- apps/web/src/shell/view-nav.ts | 18 ++-- apps/web/src/shell/view-sidebar.ts | 33 ++++++-- apps/web/src/shell/view.ts | 9 +- 14 files changed, 302 insertions(+), 62 deletions(-) diff --git a/apps/web/src/lab/shell-frame.ts b/apps/web/src/lab/shell-frame.ts index dd99c21860..889bdc871e 100644 --- a/apps/web/src/lab/shell-frame.ts +++ b/apps/web/src/lab/shell-frame.ts @@ -2,7 +2,7 @@ import { Option } from "effect" import { Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import type { Message as ShellMessage } from "../shell/message" -import type { Model as ShellModel } from "../shell/model" +import type { Model as ShellModel, ShellClerk } from "../shell/model" import { update as shellUpdate } from "../shell/update" import { type BreadcrumbEntry, frameView, menuLayerView, sheetLayerView } from "../shell/view" @@ -30,6 +30,8 @@ export const labShellView = ( currentPath: string breadcrumbs: ReadonlyArray page: () => Html + /** Who the shell is drawn for; a lab has no Clerk session, so it hands in a fixture. */ + clerk?: ShellClerk | null }>, ): ReadonlyArray => [ h.submodel({ diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 49d780bef8..2234470dbc 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -22,7 +22,8 @@ import type { Resources } from "../platform/resources" import { dashboardPage } from "../page/kit/dashboard-page" import { dismissSheetUrl, pushSheetUrl, sheetParamOf } from "../page/kit/sheet-param" import { Message as ShellMessage, Theme } from "../shell/message" -import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model" +import { init as initShell, Model as ShellModel, OnCallData, type ShellClerk } from "../shell/model" +import { NavAudience } from "../shell/nav" import type { OnCallSnapshot } from "../shell/oncall" import { foldLabShell, labShellView } from "./shell-frame" @@ -106,6 +107,7 @@ export const Model = Schema.Struct({ href: Schema.String, onCall: OnCallFixture, standOn: StandOn, + viewer: NavAudience, }) export type Model = typeof Model.Type @@ -119,6 +121,7 @@ export const Message = defineMessageUnion({ ClickedSubmitPanel: {}, ClickedOnCallFixture: { fixture: OnCallFixture }, ClickedStandOn: { standOn: StandOn }, + ClickedViewer: { viewer: NavAudience }, CompletedNavigate: {}, }) export type Message = typeof Message.Type @@ -164,6 +167,15 @@ const sheetStep = (model: Model, step: SheetStep): Return => ({ ], }) +/** A signed-in observer with nothing else read yet: all the shell needs to pick their sidebar. */ +const OBSERVER_CLERK: ShellClerk = { + maybeUser: Option.none(), + maybeOrganization: Option.none(), + memberships: [], + customer: undefined, + maybeRole: Option.some("org:observer"), +} + /** The fixture's shifts, read on the lab's fixed clock. */ const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({ ...shell, @@ -184,6 +196,7 @@ export const init = (flags: Flags): Return => { href: flags.href, onCall: "onCall", standOn: "lab", + viewer: "operator", }, commands: mapSheetCommands(sheet, toSheet), } @@ -222,6 +235,7 @@ export const update = (model: Model, message: Message): Return => model: { ...model, onCall: fixture, shell: withOnCall(model.shell, fixture) }, }), ClickedStandOn: ({ standOn }) => ({ model: { ...model, standOn } }), + ClickedViewer: ({ viewer }) => ({ model: { ...model, viewer } }), CompletedNavigate: () => ({ model }), }) @@ -341,6 +355,19 @@ const page = (h: HtmlBuilder, model: Model): Html => ], }), ), + control( + h, + "Viewer", + segmentedSelect(h, { + ariaLabel: "Viewer", + value: model.viewer, + onChange: (viewer) => Message.ClickedViewer({ viewer }), + options: [ + { value: "operator", label: "Member and above" }, + { value: "observer", label: "Observer" }, + ], + }), + ), control( h, "Current page", @@ -368,6 +395,7 @@ export const view = (model: Model, h: HtmlBuilder): Html => TooltipHost.view(h, model.tooltip, toTooltip), ...labShellView(h, model.shell, toShell, { currentPath: STAND_ON_PATH[model.standOn], + clerk: model.viewer === "observer" ? OBSERVER_CLERK : null, breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], page: () => page(h, model), }), diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index dfee746a4c..b205ea7592 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -2,6 +2,7 @@ import { InfraSurfaceLiteral } from "@maple/domain/http" import { V2Source } from "@maple/domain/http/v2" import type { BillingCustomer } from "@maple/domain/http" import type { ClerkOrganization, ClerkUser } from "../platform/clerk" +import * as Policy from "@maple/domain/policy" import { Option, Schema } from "effect" import { AsyncData } from "foldkit" import { PopupPosition } from "@maple/views/ui/anchor" @@ -15,7 +16,7 @@ import { PinnedDashboardRow } from "./dashboards-shape" import * as Palette from "./palette/model" import { Platform } from "./palette/shortcuts" import { Menu, Theme } from "./message" -import { NavSurface } from "./nav" +import { type NavAudience, NavSurface } from "./nav" import { OnCallSnapshot } from "./oncall" import { type InfraSurfaces, @@ -133,6 +134,12 @@ export type ShellClerk = Readonly<{ maybeRole: Option.Option }> +/** Who the shell is drawn for; self-hosted has no Clerk role, so it is never an observer. */ +export const shellAudience = (clerk: ShellClerk | null): NavAudience => + clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.isReadOnly([role])) + ? "observer" + : "operator" + /** A rail item's tooltip: right of the item, centred. */ export const initRailTooltip = (): Tooltip.Model => Tooltip.init("shell-rail-tooltip", { side: "right", align: "center" }) diff --git a/apps/web/src/shell/nav.test.ts b/apps/web/src/shell/nav.test.ts index f12651beb7..fec17df6ac 100644 --- a/apps/web/src/shell/nav.test.ts +++ b/apps/web/src/shell/nav.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest" -import { isNavItemActive, type NavFlags, navGroups, navPreview, partitionSubItems } from "./nav" +import { isNavItemActive, type NavFlags, navGroups, navHrefs, navPreview, partitionSubItems } from "./nav" import { Array as Arr, Option } from "effect" const infrastructure = Option.getOrUndefined( @@ -62,6 +62,39 @@ describe("isNavItemActive", () => { }) }) +describe("navGroups for an observer", () => { + const off = { cloudCost: false, releases: false, securityAudit: false, statusPages: false } + const hrefs = (flags: NavFlags) => navHrefs(navGroups(flags, "observer")) + + test("links to what is wrong and how the product is doing, and to no setup or explorer page", () => { + expect(hrefs(off)).toEqual([ + "/", + "/alerts", + "/alerts/uptime", + "/alerts/slos", + "/errors", + "/analytics", + "/users", + "/replays", + "/dashboards", + ]) + }) + + test("adds Status pages, Releases and Cost only for an org in each rollout", () => { + expect(hrefs({ ...off, statusPages: true })).toContain("/status-pages") + expect(hrefs({ ...off, releases: true })).toContain("/releases") + expect(hrefs({ ...off, cloudCost: true })).toContain("/cost") + // Security is an engineer's page, whatever the flag says. + expect(hrefs({ ...off, securityAudit: true })).not.toContain("/security") + }) + + test("marks only Incidents on an uptime page, with its Uptime row", () => { + const items = Arr.flatMap(navGroups(off, "observer"), (group) => group.items) + const active = Arr.filter(items, (item) => isNavItemActive("/alerts/uptime", item)) + expect(Arr.map(active, (item) => item.title)).toEqual(["Incidents"]) + }) +}) + describe("navGroups", () => { const titles = (flags: NavFlags) => Arr.flatMap(navGroups(flags), (group) => Arr.map(group.items, (item) => item.title)) diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index a5c643785e..b0156fc76f 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -182,6 +182,33 @@ const alertsItem: NavItem = { ], } +/** The observer's Alerts: what is wrong now and how reliable things are, without the setup pages. */ +const observerIncidentsItem: NavItem = { + title: "Incidents", + href: "/alerts", + icon: "BellIcon", + alsoActive: ["/anomalies"], + subItems: [ + { + title: "Inbox", + href: "/alerts", + icon: "BellIcon", + paletteTitle: "Incidents", + alsoActive: ["/anomalies"], + count: "alertsInbox", + }, + { title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" }, + { title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" }, + ], +} + +/** + * Who the sidebar is drawn for. An observer follows the product without operating it, so they get + * the pages that answer "is it working and how is it doing", not the ones for digging or setup. + */ +export const NavAudience = Schema.Literals(["operator", "observer"]) +export type NavAudience = typeof NavAudience.Type + /** The org rollout flags that gate a nav row each. */ export type NavFlags = Readonly<{ cloudCost: boolean @@ -190,8 +217,38 @@ export type NavFlags = Readonly<{ statusPages: boolean }> -/** `navGroups(flags)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a row each. */ -export const navGroups = (flags: NavFlags): ReadonlyArray => [ +const observerNavGroups = (flags: NavFlags): ReadonlyArray => [ + { id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] }, + { + id: "reliability", + label: "Reliability", + items: [ + observerIncidentsItem, + { title: "Errors", href: "/errors", icon: "CircleWarningIcon" }, + ...(flags.statusPages + ? [{ title: "Status pages", href: "/status-pages", icon: "WindowChartLineIcon" } as const] + : []), + ], + }, + { + id: "product", + label: "Product", + items: [ + { title: "Web analytics", href: "/analytics", icon: "ChartBarHorizontalIcon" }, + { title: "Users", href: "/users", icon: "UsersIcon" }, + { title: "Replays", href: "/replays", icon: "PlayRotateClockwiseIcon" }, + { title: "Dashboards", href: "/dashboards", icon: "GridSquareCirclePlusIcon" }, + ...(flags.releases + ? [{ title: "Releases", href: "/releases", icon: "RocketIcon" } as const] + : []), + ...(flags.cloudCost + ? [{ title: "Cost", href: "/cost", icon: "CircleDollarIcon", badge: "Beta" } as const] + : []), + ], + }, +] + +const operatorNavGroups = (flags: NavFlags): ReadonlyArray => [ { id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] }, { id: "monitor", @@ -245,6 +302,27 @@ export const navGroups = (flags: NavFlags): ReadonlyArray => [ }, ] +/** + * `navGroups(flags, audience)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a + * row each, and an observer gets {@link observerNavGroups}. + */ +export const navGroups = (flags: NavFlags, audience: NavAudience = "operator"): ReadonlyArray => + audience === "observer" ? observerNavGroups(flags) : operatorNavGroups(flags) + +/** Every page the groups link to: rows, their sub-rows and those rows' views. */ +export const navHrefs = (groups: ReadonlyArray): ReadonlyArray => + Arr.dedupe( + Arr.flatMap(groups, (group) => + Arr.flatMap(group.items, (item) => [ + item.href, + ...Arr.flatMap(item.subItems ?? [], (sub) => [ + sub.href, + ...Arr.map(sub.views ?? [], (view) => view.href), + ]), + ]), + ), + ) + /** Segment-aware prefix match: `/services` must not claim `/service-map`. */ export const isPathActive = (currentPath: string, href: string): boolean => href === "/" diff --git a/apps/web/src/shell/palette/entries.ts b/apps/web/src/shell/palette/entries.ts index cbcf5f8604..9cbe50f5a1 100644 --- a/apps/web/src/shell/palette/entries.ts +++ b/apps/web/src/shell/palette/entries.ts @@ -7,7 +7,7 @@ import { AppRoute, hrefOf } from "../../route" import type { IconName } from "@maple/views/ui/icon" import type { PinnedDashboardRow } from "../dashboards-shape" import type { Theme } from "../message" -import { navGroups } from "../nav" +import { type NavAudience, navGroups, navHrefs } from "../nav" // The palette's entries. @@ -64,28 +64,41 @@ export const FLAGGED_HREFS: ReadonlySet = new Set([ const icon = (name: IconName): Glyph => ({ _tag: "Icon", name }) const href = (url: string): Target => ({ _tag: "Href", href: url }) +const EVERY_FLAG = { cloudCost: true, releases: true, securityAudit: true, statusPages: true } + +/** The pages an observer's sidebar links to, plus their account. */ +const OBSERVER_HREFS: ReadonlySet = new Set([ + ...navHrefs(navGroups(EVERY_FLAG, "observer")), + "/account", +]) + +/** Whether the viewer's palette lists the entry: an observer gets their own pages, dashboards and the app actions. */ +export const isForAudience = (entry: PaletteEntry, audience: NavAudience): boolean => + audience === "operator" || + entry.group === "Actions" || + entry.group === "Dashboards" || + (entry.group === "Navigation" && entry.target._tag === "Href" && OBSERVER_HREFS.has(entry.target.href)) + /** Every section, its children and their views, deduplicated by title and href. */ const navigationEntries = (): ReadonlyArray => { - const rows = Arr.flatMap( - navGroups({ cloudCost: true, releases: true, securityAudit: true, statusPages: true }), - (group) => - Arr.flatMap(group.items, (item) => [ - { id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }, - ...Arr.flatMap(item.subItems ?? [], (sub) => [ - { - id: `nav:${item.title}:${sub.title}`, - title: sub.paletteTitle ?? sub.title, - href: sub.href, - icon: sub.icon ?? item.icon, - }, - ...Arr.map(sub.views ?? [], (view) => ({ - id: `nav:${item.title}:${sub.title}:${view.title}`, - title: view.paletteTitle ?? `${sub.title} ${view.title}`, - href: view.href, - icon: view.paletteIcon ?? sub.icon ?? item.icon, - })), - ]), + const rows = Arr.flatMap(navGroups(EVERY_FLAG), (group) => + Arr.flatMap(group.items, (item) => [ + { id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }, + ...Arr.flatMap(item.subItems ?? [], (sub) => [ + { + id: `nav:${item.title}:${sub.title}`, + title: sub.paletteTitle ?? sub.title, + href: sub.href, + icon: sub.icon ?? item.icon, + }, + ...Arr.map(sub.views ?? [], (view) => ({ + id: `nav:${item.title}:${sub.title}:${view.title}`, + title: view.paletteTitle ?? `${sub.title} ${view.title}`, + href: view.href, + icon: view.paletteIcon ?? sub.icon ?? item.icon, + })), ]), + ]), ) // One row per page, titled by its most specific row ("Traces", not its "Explore" parent); // every title that opens the same page stays searchable as a keyword. diff --git a/apps/web/src/shell/palette/global-shortcuts.test.ts b/apps/web/src/shell/palette/global-shortcuts.test.ts index 25553890eb..1ed9b8ac96 100644 --- a/apps/web/src/shell/palette/global-shortcuts.test.ts +++ b/apps/web/src/shell/palette/global-shortcuts.test.ts @@ -59,7 +59,12 @@ describe("global shortcuts", () => { const opened = step(signedIn(), Message.ClickedSearch()).model const entered = step( opened, - toPalette(Palette.Message.PressedEnter({ visibleFlagged: ["/releases", "/security"] })), + toPalette( + Palette.Message.PressedEnter({ + visibleFlagged: ["/releases", "/security"], + audience: "operator", + }), + ), ) expect(entered.outMessage).toEqual({ _tag: "RequestedNavigation", url: "/" }) }) diff --git a/apps/web/src/shell/palette/model.ts b/apps/web/src/shell/palette/model.ts index 91825017b2..96d1d42899 100644 --- a/apps/web/src/shell/palette/model.ts +++ b/apps/web/src/shell/palette/model.ts @@ -2,6 +2,7 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" +import { NavAudience } from "../nav" import { PaletteAction } from "./entries" // The command palette: mounted while open, so @@ -32,7 +33,7 @@ export const Message = defineMessageUnion({ /** A pick: a click on an entry or Enter on the highlighted one (`items` in list order). */ ClickedEntry: { id: Schema.String }, /** Enter in the input; the view knows whether the org shows Releases. */ - PressedEnter: { visibleFlagged: Schema.Array(Schema.String) }, + PressedEnter: { visibleFlagged: Schema.Array(Schema.String), audience: NavAudience }, SucceededLoadServices: { serviceNames: Schema.Array(Schema.String) }, FailedLoadServices: {}, CompletedReleaseServices: {}, diff --git a/apps/web/src/shell/palette/palette.test.ts b/apps/web/src/shell/palette/palette.test.ts index 59cfda351c..89fa47071a 100644 --- a/apps/web/src/shell/palette/palette.test.ts +++ b/apps/web/src/shell/palette/palette.test.ts @@ -106,7 +106,7 @@ describe("command palette", () => { const model = typed(open().model, "logs").model const pressed = update( model, - Message.PressedEnter({ visibleFlagged: ["/releases", "/security"] }), + Message.PressedEnter({ visibleFlagged: ["/releases", "/security"], audience: "operator" }), context, ) expect(pressed.outMessage).toEqual(OutMessage.Navigated({ href: "/logs" })) @@ -151,6 +151,37 @@ describe("command palette", () => { expect(titles).toEqual(expect.arrayContaining(["Connect Slack", "Connect Discord"])) }) + test("an observer finds their own pages and dashboards, not setup or the explorers", () => { + const model = loadedWith(["storefront"]) + const shown = (query: string) => + Arr.map( + shownEntries(typed(model, query).model, context, ["/releases", "/cost"], "observer").entries, + (entry) => entry.title, + ) + expect(shown("analytics")).toContain("Web analytics") + expect(shown("checkout")).toContain("Checkout health") + expect(shown("traces")).not.toContain("Traces") + expect(shown("connect")).toEqual([]) + expect(shown("storefront")).not.toContain("storefront") + expect(shown("settings")).not.toContain("Settings") + // The same queries still find them for everyone else. + const operator = Arr.map( + shownEntries(typed(model, "traces").model, context, ALL_FLAGGED, "operator").entries, + (entry) => entry.title, + ) + expect(operator).toContain("Traces") + }) + + test("Enter opens the page an observer sees highlighted, not the full list's", () => { + const model = typed(loadedWith([]), "uptime").model + const result = update( + model, + Message.PressedEnter({ visibleFlagged: [], audience: "observer" }), + context, + ) + expect(result.outMessage).toEqual(OutMessage.Navigated({ href: "/alerts/uptime" })) + }) + test("a flagged-off row is not shown", () => { const titles = (visibleFlagged: ReadonlyArray) => Arr.map(shownEntries(open().model, context, visibleFlagged).entries, (entry) => entry.title) diff --git a/apps/web/src/shell/palette/update.ts b/apps/web/src/shell/palette/update.ts index 8c3decf94c..19edd25824 100644 --- a/apps/web/src/shell/palette/update.ts +++ b/apps/web/src/shell/palette/update.ts @@ -4,6 +4,7 @@ import Fuse from "fuse.js" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import type { Resources } from "../../platform/resources" +import type { NavAudience } from "../nav" import { browseEntries, type EntryInputs, @@ -12,6 +13,7 @@ import { type PaletteEntry, paletteEntries, searchEntriesOf, + isForAudience, } from "./entries" import { LoadPaletteServices, ScrollHighlighted } from "./command" import { init, Message, type Model, OutMessage } from "./model" @@ -61,13 +63,15 @@ const computeShownEntries = ( model: Model, context: Context, visibleFlagged: ReadonlyArray, + audience: NavAudience, ): Shown => { const all = entriesOf(model, context) const entries = Arr.filter( all, (entry) => - entry.isFlagged !== true || - (entry.target._tag === "Href" && visibleFlagged.includes(entry.target.href)), + isForAudience(entry, audience) && + (entry.isFlagged !== true || + (entry.target._tag === "Href" && visibleFlagged.includes(entry.target.href))), ) const query = Str.trim(model.list.inputValue) if (query === "") return { entries: browseEntries(entries), isSearch: false } @@ -84,7 +88,7 @@ const sameItems = (a: ReadonlyArray, b: ReadonlyArray) => type ShownInputs = Readonly<{ query: string; serviceNames: ReadonlyArray }> & Context & - Readonly<{ visibleFlagged: ReadonlyArray }> + Readonly<{ visibleFlagged: ReadonlyArray; audience: NavAudience }> const sameInputs = (a: ShownInputs, b: ShownInputs) => a.query === b.query && @@ -92,7 +96,8 @@ const sameInputs = (a: ShownInputs, b: ShownInputs) => sameItems(a.serviceNames, b.serviceNames) && sameItems(a.dashboards, b.dashboards) && sameItems(a.favorites, b.favorites) && - sameItems(a.visibleFlagged, b.visibleFlagged) + sameItems(a.visibleFlagged, b.visibleFlagged) && + a.audience === b.audience /** Every render rebuilds the context, so the last answer is kept by its inputs, not by reference. */ let lastShown: { inputs: ShownInputs; shown: Shown } | null = null @@ -101,23 +106,30 @@ export const shownEntries = ( model: Model, context: Context, visibleFlagged: ReadonlyArray, + audience: NavAudience = "operator", ): Shown => { const inputs: ShownInputs = { ...context, query: model.list.inputValue, serviceNames: model.serviceNames, visibleFlagged, + audience, } if (lastShown !== null && sameInputs(lastShown.inputs, inputs)) return lastShown.shown - const shown = computeShownEntries(model, context, visibleFlagged) + const shown = computeShownEntries(model, context, visibleFlagged, audience) lastShown = { inputs, shown } return shown } /** The entries in list order (grouped), what the highlight indexes. */ -const listedEntries = (model: Model, context: Context, visibleFlagged: ReadonlyArray) => +const listedEntries = ( + model: Model, + context: Context, + visibleFlagged: ReadonlyArray, + audience: NavAudience, +) => Arr.flatMap( - groupEntries(shownEntries(model, context, visibleFlagged).entries), + groupEntries(shownEntries(model, context, visibleFlagged, audience).entries), (section) => section.entries, ) @@ -143,8 +155,13 @@ const toList = Command.mapMessage((message: Combobox.Message) => Message.GotList const toDialog = Command.mapMessage((message: Dialog.Message) => Message.GotDialogMessage({ message })) /** Base UI's Enter: the highlighted entry is picked. */ -const enter = (model: Model, context: Context, visibleFlagged: ReadonlyArray): UpdateReturn => - picked(model, listedEntries(model, context, visibleFlagged)[model.list.highlighted ?? -1]) +const enter = ( + model: Model, + context: Context, + visibleFlagged: ReadonlyArray, + audience: NavAudience, +): UpdateReturn => + picked(model, listedEntries(model, context, visibleFlagged, audience)[model.list.highlighted ?? -1]) /** The inline autocomplete: typing searches, arrows move the highlight into view, Enter picks. */ const listStep = (model: Model, child: Combobox.Message, context: Context): UpdateReturn => { @@ -176,7 +193,7 @@ export const update = (model: Model, message: Message, context: Context): Update } }, GotListMessage: ({ message: child }) => listStep(model, child, context), - PressedEnter: ({ visibleFlagged }) => enter(model, context, visibleFlagged), + PressedEnter: ({ visibleFlagged, audience }) => enter(model, context, visibleFlagged, audience), ClickedEntry: ({ id }) => { const entry = findEntry(model, context, id) // A link's own click navigates (the root routes it), so only the palette closes. diff --git a/apps/web/src/shell/palette/view.ts b/apps/web/src/shell/palette/view.ts index a5d683bbf6..df9b5316a9 100644 --- a/apps/web/src/shell/palette/view.ts +++ b/apps/web/src/shell/palette/view.ts @@ -16,6 +16,7 @@ import { kbd } from "@maple/views/ui/display" import { icon } from "@maple/views/ui/icon" import { manifestGlyph } from "@maple/views/ui/manifest-glyph" import { serviceDot } from "@maple/views/ui/values" +import type { NavAudience } from "../nav" import { type Glyph, groupEntries, type PaletteEntry } from "./entries" import { Message, type Model } from "./model" import { type Context, shownEntries } from "./update" @@ -32,7 +33,11 @@ const PALETTE_LIST_CLASS = // The command palette's popup: the input, the grouped entries (or "No results"), the footer hints. -export type ViewInputs = Readonly<{ context: Context; visibleFlagged: ReadonlyArray }> +export type ViewInputs = Readonly<{ + context: Context + visibleFlagged: ReadonlyArray + audience: NavAudience +}> const GLYPH_CLASS = "shrink-0 text-muted-foreground" @@ -81,10 +86,10 @@ export const paletteView = ( const toList = (message: Combobox.Message) => toMessage( message._tag === "PressedInputKey" && message.key === "Enter" - ? Message.PressedEnter({ visibleFlagged: inputs.visibleFlagged }) + ? Message.PressedEnter({ visibleFlagged: inputs.visibleFlagged, audience: inputs.audience }) : Message.GotListMessage({ message }), ) - const shown = shownEntries(model, inputs.context, inputs.visibleFlagged) + const shown = shownEntries(model, inputs.context, inputs.visibleFlagged, inputs.audience) const groups = groupsOf(h, shown.entries) return h.submodel({ slotId: "command-palette", diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 120e133a2c..2825a186b8 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -21,7 +21,7 @@ import { } from "@maple/views/ui/sidebar" import type { PinnedDashboardRow } from "./dashboards-shape" import { Menu, Message } from "./message" -import { type Model, type ShellClerk, shellProbe, shellSurfaces } from "./model" +import { type Model, type ShellClerk, shellAudience, shellProbe, shellSurfaces } from "./model" import { isNavItemActive, matchSubItem, @@ -43,18 +43,22 @@ import { typography } from "@maple/views/ui/typography" * Security also needs a role with `security:manage`: members never see it. */ export const shellNavGroups = (clerk: ShellClerk | null = null): ReadonlyArray => { + const audience = shellAudience(clerk) const flag = (name: keyof NavFlags) => !isClerkAuthEnabled || (clerk !== null && Option.exists(clerk.maybeOrganization, (organization) => organization.flags[name])) const canReadSecurity = !isClerkAuthEnabled || (clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.can([role], "security:manage"))) - return navGroups({ - cloudCost: flag("cloudCost"), - releases: flag("releases"), - securityAudit: flag("securityAudit") && canReadSecurity, - statusPages: flag("statusPages"), - }) + return navGroups( + { + cloudCost: flag("cloudCost"), + releases: flag("releases"), + securityAudit: flag("securityAudit") && canReadSecurity, + statusPages: flag("statusPages"), + }, + audience, + ) } /** The last-hour probe's answer; `null` while it is first in flight or after it failed. */ diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 2582590afd..6e4392524f 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -19,7 +19,7 @@ import { } from "@maple/views/ui/sidebar" import { isSameMenu, triggerIdOf } from "./menus" import { Menu, Message } from "./message" -import type { Model, ShellClerk } from "./model" +import { type Model, type ShellClerk, shellAudience } from "./model" import * as ClerkOrgSwitcherView from "./clerk-org-switcher-view" import { orgAvatar } from "./clerk-org-switcher-view" import { typography } from "@maple/views/ui/typography" @@ -405,14 +405,29 @@ export const shellSidebarFooter = ( sidebarMenu(h, undefined, [ // A row of the footer's list, so it keeps the list's 4px rhythm. ...onCallPill(h, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), - footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), - // `/integrations` renders inside the settings rail, so Settings owns it. - footerNavRow( - h, - currentPath, - { href: "/settings", title: "Settings", icon: "GearIcon", alsoActive: "/integrations" }, - tip, - ), + // An observer sets nothing up: their account is in the user menu below. + ...(shellAudience(clerk) === "observer" + ? [] + : [ + footerNavRow( + h, + currentPath, + { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, + tip, + ), + // `/integrations` renders inside the settings rail, so Settings owns it. + footerNavRow( + h, + currentPath, + { + href: "/settings", + title: "Settings", + icon: "GearIcon", + alsoActive: "/integrations", + }, + tip, + ), + ]), ]), h.div( [h.Class("-mx-2 border-sidebar-border border-t px-2 pt-2")], diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index 0e5f36cf29..c6fac636cd 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -18,7 +18,7 @@ import { placementOf, popupIdOf, triggerIdOf } from "./menus" import { billingBanners } from "./billing-banners" import * as SupportChannelView from "./support-channel-view" import { Message } from "./message" -import type { Model, ShellClerk } from "./model" +import { type Model, type ShellClerk, shellAudience } from "./model" import { MeasureSidebarOverflow, PositionMenu } from "./mounts" import { isMenuKey } from "./update" import { menuContent } from "./view-menus" @@ -68,8 +68,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), ...(isPillShown(onCall) ? [onCallDetail(onCall)] : []), - "Sources", - "Settings", + ...(shellAudience(clerk) === "observer" ? [] : ["Sources", "Settings"]), ] } @@ -196,7 +195,8 @@ export const frameView = Submodel.defineView((model inputs.breadcrumbs, inputs.topbarActions(), railTriggers, - !hidesTopbarConnect(inputs.currentPath), + !hidesTopbarConnect(inputs.currentPath) && + shellAudience(inputs.clerk ?? null) !== "observer", inputs.sourceLink ?? null, ), ...(inputs.clerk === undefined || inputs.clerk === null @@ -301,6 +301,7 @@ export const paletteLayerView = Submodel.defineView Arr.flatMap(group.items, (item) => FLAGGED_HREFS.has(item.href) ? [item.href] : [], From cb42d733505a27c536f8ea60f655a1ae6da63b3d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 01:21:20 +0100 Subject: [PATCH 2/3] fix(shell): list an observer's palette pages under their sidebar's names An observer's sidebar calls the alerts inbox Incidents, but the palette built its rows from the full nav, so it listed that page as Alerts and a search for incidents found nothing. Their entries now carry the observer nav's titles; the ids are unchanged, so a pick resolves the same way. --- apps/web/src/shell/nav.test.ts | 13 +++++- apps/web/src/shell/nav.ts | 14 ------ apps/web/src/shell/palette/entries.ts | 50 ++++++++++++++-------- apps/web/src/shell/palette/palette.test.ts | 3 ++ apps/web/src/shell/palette/update.ts | 9 ++-- 5 files changed, 51 insertions(+), 38 deletions(-) diff --git a/apps/web/src/shell/nav.test.ts b/apps/web/src/shell/nav.test.ts index fec17df6ac..e9bc82a8c5 100644 --- a/apps/web/src/shell/nav.test.ts +++ b/apps/web/src/shell/nav.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest" -import { isNavItemActive, type NavFlags, navGroups, navHrefs, navPreview, partitionSubItems } from "./nav" +import { isNavItemActive, type NavFlags, navGroups, navPreview, partitionSubItems } from "./nav" import { Array as Arr, Option } from "effect" const infrastructure = Option.getOrUndefined( @@ -64,7 +64,16 @@ describe("isNavItemActive", () => { describe("navGroups for an observer", () => { const off = { cloudCost: false, releases: false, securityAudit: false, statusPages: false } - const hrefs = (flags: NavFlags) => navHrefs(navGroups(flags, "observer")) + /** Every page the observer's sidebar links to: rows, then their sub-rows. */ + const hrefs = (flags: NavFlags) => + Arr.dedupe( + Arr.flatMap(navGroups(flags, "observer"), (group) => + Arr.flatMap(group.items, (item) => [ + item.href, + ...Arr.map(item.subItems ?? [], (sub) => sub.href), + ]), + ), + ) test("links to what is wrong and how the product is doing, and to no setup or explorer page", () => { expect(hrefs(off)).toEqual([ diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index b0156fc76f..4e96819ddf 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -309,20 +309,6 @@ const operatorNavGroups = (flags: NavFlags): ReadonlyArray => [ export const navGroups = (flags: NavFlags, audience: NavAudience = "operator"): ReadonlyArray => audience === "observer" ? observerNavGroups(flags) : operatorNavGroups(flags) -/** Every page the groups link to: rows, their sub-rows and those rows' views. */ -export const navHrefs = (groups: ReadonlyArray): ReadonlyArray => - Arr.dedupe( - Arr.flatMap(groups, (group) => - Arr.flatMap(group.items, (item) => [ - item.href, - ...Arr.flatMap(item.subItems ?? [], (sub) => [ - sub.href, - ...Arr.map(sub.views ?? [], (view) => view.href), - ]), - ]), - ), - ) - /** Segment-aware prefix match: `/services` must not claim `/service-map`. */ export const isPathActive = (currentPath: string, href: string): boolean => href === "/" diff --git a/apps/web/src/shell/palette/entries.ts b/apps/web/src/shell/palette/entries.ts index 9cbe50f5a1..0f074f4f3b 100644 --- a/apps/web/src/shell/palette/entries.ts +++ b/apps/web/src/shell/palette/entries.ts @@ -7,7 +7,7 @@ import { AppRoute, hrefOf } from "../../route" import type { IconName } from "@maple/views/ui/icon" import type { PinnedDashboardRow } from "../dashboards-shape" import type { Theme } from "../message" -import { type NavAudience, navGroups, navHrefs } from "../nav" +import { type NavAudience, type NavGroup, navGroups } from "../nav" // The palette's entries. @@ -66,22 +66,9 @@ const href = (url: string): Target => ({ _tag: "Href", href: url }) const EVERY_FLAG = { cloudCost: true, releases: true, securityAudit: true, statusPages: true } -/** The pages an observer's sidebar links to, plus their account. */ -const OBSERVER_HREFS: ReadonlySet = new Set([ - ...navHrefs(navGroups(EVERY_FLAG, "observer")), - "/account", -]) - -/** Whether the viewer's palette lists the entry: an observer gets their own pages, dashboards and the app actions. */ -export const isForAudience = (entry: PaletteEntry, audience: NavAudience): boolean => - audience === "operator" || - entry.group === "Actions" || - entry.group === "Dashboards" || - (entry.group === "Navigation" && entry.target._tag === "Href" && OBSERVER_HREFS.has(entry.target.href)) - -/** Every section, its children and their views, deduplicated by title and href. */ -const navigationEntries = (): ReadonlyArray => { - const rows = Arr.flatMap(navGroups(EVERY_FLAG), (group) => +/** A page's palette row under each title the nav gives it: the section, its children, their views. */ +const navRowsOf = (groups: ReadonlyArray) => + Arr.flatMap(groups, (group) => Arr.flatMap(group.items, (item) => [ { id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }, ...Arr.flatMap(item.subItems ?? [], (sub) => [ @@ -100,6 +87,35 @@ const navigationEntries = (): ReadonlyArray => { ]), ]), ) + +/** What an observer's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */ +const OBSERVER_TITLES: ReadonlyMap = new Map([ + ...Arr.map(navRowsOf(navGroups(EVERY_FLAG, "observer")), (row) => [row.href, row.title] as const), + ["/account", "Account"] as const, +]) + +/** + * The entries as the viewer's palette lists them. An observer gets their own pages under their + * sidebar's names, dashboards and the app actions; the ids stay, so a pick resolves either way. + */ +export const entriesFor = ( + entries: ReadonlyArray, + audience: NavAudience, +): ReadonlyArray => + audience === "operator" + ? entries + : Arr.flatMap(entries, (entry): ReadonlyArray => { + if (entry.group === "Actions" || entry.group === "Dashboards") return [entry] + if (entry.group !== "Navigation" || entry.target._tag !== "Href") return [] + const title = OBSERVER_TITLES.get(entry.target.href) + return title === undefined + ? [] + : [{ ...entry, title, keywords: `${entry.keywords} ${title}` }] + }) + +/** Every section, its children and their views, deduplicated by title and href. */ +const navigationEntries = (): ReadonlyArray => { + const rows = navRowsOf(navGroups(EVERY_FLAG)) // One row per page, titled by its most specific row ("Traces", not its "Explore" parent); // every title that opens the same page stays searchable as a keyword. const unique = Arr.reverse(Arr.dedupeWith(Arr.reverse(rows), (a, b) => a.href === b.href)) diff --git a/apps/web/src/shell/palette/palette.test.ts b/apps/web/src/shell/palette/palette.test.ts index 89fa47071a..afa5a94390 100644 --- a/apps/web/src/shell/palette/palette.test.ts +++ b/apps/web/src/shell/palette/palette.test.ts @@ -159,6 +159,9 @@ describe("command palette", () => { (entry) => entry.title, ) expect(shown("analytics")).toContain("Web analytics") + // Their sidebar calls the alerts inbox Incidents, so that is what they search for and see. + expect(shown("incidents")).toContain("Incidents") + expect(shown("alerts")).not.toContain("Alerts") expect(shown("checkout")).toContain("Checkout health") expect(shown("traces")).not.toContain("Traces") expect(shown("connect")).toEqual([]) diff --git a/apps/web/src/shell/palette/update.ts b/apps/web/src/shell/palette/update.ts index 19edd25824..5ec54f2993 100644 --- a/apps/web/src/shell/palette/update.ts +++ b/apps/web/src/shell/palette/update.ts @@ -7,13 +7,13 @@ import type { Resources } from "../../platform/resources" import type { NavAudience } from "../nav" import { browseEntries, + entriesFor, type EntryInputs, groupEntries, MAX_RESULTS, type PaletteEntry, paletteEntries, searchEntriesOf, - isForAudience, } from "./entries" import { LoadPaletteServices, ScrollHighlighted } from "./command" import { init, Message, type Model, OutMessage } from "./model" @@ -65,13 +65,12 @@ const computeShownEntries = ( visibleFlagged: ReadonlyArray, audience: NavAudience, ): Shown => { - const all = entriesOf(model, context) + const all = entriesFor(entriesOf(model, context), audience) const entries = Arr.filter( all, (entry) => - isForAudience(entry, audience) && - (entry.isFlagged !== true || - (entry.target._tag === "Href" && visibleFlagged.includes(entry.target.href))), + entry.isFlagged !== true || + (entry.target._tag === "Href" && visibleFlagged.includes(entry.target.href)), ) const query = Str.trim(model.list.inputValue) if (query === "") return { entries: browseEntries(entries), isSearch: false } From 5f07072e96980accd86fc2249725eed27526f9c7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 01:53:43 +0100 Subject: [PATCH 3/3] test(shell): pin the observer shell's missing setup rows and Connect button --- .../src/lab/shell-states-lab.scene.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 7499d93bff..870399739b 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -139,3 +139,31 @@ describe("URL-held right sheet", () => { ) }) }) + +describe("the shell for an observer", () => { + const setup = [ + role("link", { name: "Sources" }), + role("link", { name: "Settings" }), + role("button", { name: "Connect" }), + ] + + test("a member has the Sources and Settings rows and the Connect button", () => { + scene( + { update, view }, + given(at("/lab/shell")), + measured, + ...Arr.map(setup, (it) => expect(it).toExist()), + ) + }) + + test("an observer has none of them, and keeps their own pages", () => { + scene( + { update, view }, + given(at("/lab/shell")), + measured, + click(role("button", { name: "Observer" })), + ...Arr.map(setup, (it) => expect(it).toBeAbsent()), + expect(role("link", { name: "Web analytics" })).toExist(), + ) + }) +})