Compare commits

...
11 Commits
Author SHA1 Message Date
hanzo-dev e35aa8c693 the rail shows one level, and the catalog is not an allow-list
Hanzo CI/CD / cicd (push) Successful in 4m27s
CI/CD / cicd (push) Successful in 4m32s
Five defects, and each was one thing said twice.

LEVEL. A product's pages expanded indented beneath its row while the whole
catalog stayed painted below, so two levels shared the screen — and a pinned
product appeared twice, one copy carrying the pages and the other inert. The
level is a replacement now, and a function of the route alone: the category (the
way back up), the product as a heading, its pages flush, then its siblings.
Drops NAV_PRODUCT_OPEN_PREF, productIsOpen, toggleProduct, the rule that picked
which duplicate row was real, and the inert accordion. One SubRow serves the
full console and the product-shell face, which had its own copy and a private
icon map that was a subset of the shared one.

CATALOG. filterBeta kept only LAUNCH_PRODUCTS for anyone who was not a
superadmin: 13 of 185 products, 6 of 14 categories, behind a word ("beta") that
no entry actually carried. Gone, with isLaunchProduct, isBetaEntry,
CatalogEntry.beta, useAppsBeta and the showBeta argument threaded through the
catalog, the search, the rail, the palette and the panel. That left the panel
saying every product is always available while the rail still filtered by the
org's enabled set — one catalog, two sizes. navCatalog settles it: on, the rail
is everything the viewer may see; off, it narrows to the enabled set plus pins.
Permission holds either way. An org whose entitlement plane answers {enabled:[]}
now sees all eleven categories instead of collapsing to the essentials.

SEARCH. The rail's own text filter narrowed only the rows already drawn — a
second search beside the header palette and a third copy in the drawer. All
three are CommandSearchBox now, so the rail asks the whole catalog from either
level.

IDENTITY. The org's name was replaced by its logo whenever IAM carried one,
surviving only in the aria-label; the logo is the mark now and the name is the
label. The account menu was @hanzo/iam's UserMenu — a second rendering system in
one rail, raw DOM through an injected global stylesheet, a circle where the org
wore a square. Both ends are one Menu on @hanzo/gui. Identity still comes from
IAM. The balance leaves the menu: SidebarWallet shows the same number one row
below, with more. One orgLabel, so the trigger and the row for the same org
cannot read differently, and SidebarBrand stops wearing the house glyph for an
org that has no logo of its own.

paper declared bordered and left the width at zero, so every anchored sheet met
the page with no edge; the menu took no z-layer, so on a phone it opened inside
the account sheet unclickable, and upward off the top of the screen.

3340 unit tests, 22 browser tests across rail, account-menu, level-2-nav and
find-and-do. e2e/rail.spec.ts measures the flush left edge, the absent
neighbours, the single naming of the product, and the two triggers' shared box.
2026-08-07 09:04:46 -07:00
zooqueen 129599fe9e a card states the role it was granted, not one inferred from the account
Hanzo CI/CD / cicd (push) Successful in 6m9s
CI/CD / cicd (push) Successful in 6m10s
roleFor's last branch carried a comment calling itself unreachable —
'a non-global-admin only ever sees their own org' — and listing
memberships is exactly what made it reachable. Every joined org then
rendered 'Member', including ones the person administers: measured
live, dave is admin of maxpower and his maxpower card said Member.

The membership row is consulted first, because once a person can see
more than one org, inferring a role from their account is a guess.
2026-08-07 06:18:09 -07:00
zooqueen a28b0e796d restore the OrgMark import the two concurrent fixes cancelled
Hanzo CI/CD / cicd (push) Successful in 9m54s
CI/CD / cicd (push) Successful in 10m24s
Two sessions repaired the same duplicate import at once — one dropped
the first declaration, the other dropped the second — and rebasing
applied both, so the switcher used OrgMark twice while importing it
zero times. tsc says it plainly (TS2304 Cannot find name 'OrgMark');
the bundler would have said it again.

Verified AFTER the rebase this time, which is the actual lesson: a
check that ran before the merge proves nothing about what landed.
2026-08-06 21:23:59 -07:00
zooqueen a1a13cdc25 the switcher imports OrgMark once, so the console can build again
Hanzo CI/CD / cicd (push) Failing after 1m41s
CI/CD / cicd (push) Failing after 1m51s
2b5786685 added a second 'import { OrgMark, SearchInput }' beside the
existing OrgMark import in ContextSwitcher, and webpack refuses a
re-declared identifier: 'Module parse failed: Identifier OrgMark has
already been declared'. Every console image since has failed at
build:embed, so nothing has shipped — the tests and typecheck never saw
it because both run before the bundler does.

One import, both names.
2026-08-06 21:22:42 -07:00
hanzo-dev 60b906b448 console: one OrgMark import, so the bundle compiles
Hanzo CI/CD / cicd (push) Successful in 4m43s
CI/CD / cicd (push) Successful in 4m56s
The file already imported OrgMark for the trigger; adding it again for the rows
re-declared the identifier. tsc did not object, webpack did — the bundle is the
gate here.
2026-08-06 21:18:38 -07:00
zooqueen 49d11bb9bc the picker lists the orgs you belong to, and names them by the org
Hanzo CI/CD / cicd (push) Failing after 2m29s
CI/CD / cicd (push) Failing after 2m47s
A non-super caller got ONE card, synthesized from the session, titled
with account.displayName — the signed-in PERSON where the organization
belongs, with the person's initials as its mark. A customer who works
in a second workspace could not see it at all, because the list was
derived from the account's owner rather than from the memberships that
actually say which orgs a person may act in.

Now it reads the membership set (home org first, deduped — pure, so the
ordering is tested without a network) and loads each org's own record,
so a card carries the ORG's name and logo. IAM authorizes a member to
read the orgs they belong to as of v1.34.26, which is what makes the
per-org read answer instead of 403. A row that still cannot be read
degrades to its slug rather than dropping an org the person is entitled
to see.
2026-08-06 21:17:32 -07:00
hanzo-dev 2b5786685d console: the org switcher wears marks, and names the way into its own settings
Hanzo CI/CD / cicd (push) Failing after 1m18s
CI/CD / cicd (push) Failing after 1m27s
The switcher listed orgs as bare text while the account menu beside it wore an
avatar, so two rows of the same chrome read as two different products. Each org
now shows the ONE shared OrgMark the account widget already uses.

An org with no logo wore its initial. It now wears the HOST's brand — the Hanzo
mark on console.hanzo.ai, whatever a white-labelled host resolves to elsewhere —
so a workspace reads as its own from the first sign-in rather than as a letter.
Uploading a logo still overrides it.

Branding was reachable only by knowing the URL; the switcher now says where it
is.
2026-08-06 21:01:41 -07:00
zooqueen a33acca87f console 8.5.78 — re-release the terminal dock (8.5.77's image OOM'd in CI)
Hanzo CI/CD / cicd (push) Successful in 5m59s
CI/CD / cicd (push) Successful in 6m9s
No code change from ff9d42e0 (the dock that frames cloud's /v1/sandboxes/:id/terminal
page); the 8.5.77 build died on the ARC runner, so this re-triggers a clean build.
2026-08-06 20:50:11 -07:00
zooqueen ff9d42e0ba console 8.5.77 — the dock frames the terminal cloud serves
Hanzo CI/CD / cicd (push) Successful in 13m2s
CI/CD / cicd (push) Successful in 13m2s
Cloud now serves the terminal whole — emulator, socket, resize, reconnect, one
self-contained page at the same address as the socket — so the console frames it
instead of building one. That is not laziness about an emulator: the console is
one of several hosts that show a shell, and a terminal built per host is a
terminal that is subtly different in each of them. One implementation, one place
a fix lands, and @xterm leaves this repo's dependencies entirely.

What is left here is the only part that was ever the console's: which sandbox,
and what to say while it comes up. The credential too — a frame carries no
Authorization header any more than a socket does, so the ticket is fetched
through the same-origin /v1 proxy, where identity lives, and handed to the page
in its URL. Single-use and thirty seconds is what makes that safe.

The dock names its tmux session, so reopening it reattaches to the shell it left
rather than opening a fresh one over the user's work.

Readiness is a HANDSHAKE, not a guess: the page posts {source:'hanzo-term'} when
its socket opens, and only the frame we opened, from the API origin, is allowed
to speak for it. Without a deadline on that message a frame that failed into
something else is indistinguishable from one still loading, and the dock would
sit on "Starting…" forever instead of offering the reconnect that fixes it.

Ticket path follows cloud: /v1/sandboxes/:id/terminal/ticket.
2026-08-06 19:07:57 -07:00
zooqueen a70a66b1cc the level-2 spec asserted the sub-nav order the code stopped shipping
Hanzo CI/CD / cicd (push) Successful in 5m59s
CI/CD / cicd (push) Successful in 10m27s
f6df104ec8 moved Status to the end of BASE_SUBPAGES and updated match-core.test.ts,
but this browser spec carries its own copy of the expected labels and was missed. It
asserted Settings · Status · Logs · Metrics, so it failed against every build from
8.5.75 on — measured live on console.hanzo.ai, where the deployed bundle serves
Settings · Logs · Metrics · Status.

The spec is right to hold the whole list rather than just the tail: it is the one
place the ORDER a phone actually paints is checked, and a partial assertion is how
the order drifted in the first place.
2026-08-06 19:00:22 -07:00
zooqueen 344bb1cfc2 console 8.5.76 — cloud shell in the Developers dock
Hanzo CI/CD / cicd (push) Successful in 22m58s
CI/CD / cicd (push) Successful in 22m59s
The Shell tab was an explorer wearing a terminal's clothes: a `$` prompt that
took `GET /v1/models` and printed the response. Nothing could be RUN in it, so
the prompt was a promise the dock could not keep. It is a terminal now — a login
shell on a pty inside the org's own sandbox, over
/v1/sandboxes/:id/terminal/ws. Whatever the image carries, the hanzo CLI
included, is a command the user types; nothing here decides what may run, because
that decision belongs to the runtime boundary the pod already has and a second
one in a browser tab would only be a fiction.

The socket leaves the origin, once and deliberately. Every other read goes
through the same-origin /v1 proxy, which mints a user-bound bearer server-side —
and a WebSocket cannot use it, because that proxy is a route handler and a route
handler forwards requests, not upgrades. So the two halves split: the ticket is
fetched through the proxy exactly like every other call, and the socket dials the
API host carrying it. Single-use, thirty seconds, bound to one sandbox. That is
what makes putting it in a URL safe, and why nothing long-lived ever goes there.

`sandboxes` joins CLOUD_HEADS so the ticket can be fetched at all. The socket is
not and cannot be proxied — see the note beside the entry.

Which sandbox is "mine" is a QUERY, not a stored id: one live sandbox per project
is the server's own rule, so reopening the dock finds the running one and a
reload has nothing to go stale. The `console` project names the volume, which is
what makes it the same shell tomorrow.

Honest states throughout — "Starting your cloud shell…", then the terminal, then
a calm Disconnected with the reason and a Reconnect that restarts the whole
session as a unit. Never a fake prompt.

xterm loads in the browser and not at import: it reads `document` as it
constructs and this component is server-rendered first. Its element is always
laid out and the status covers it, because an element that is display:none has no
size and the fit that runs when the socket opens would hand the shell an 80x24
window it never corrects.

The /v1 explorer's parser goes with it — `parseCommand` and `hanzoCli` had one
caller each and it was the tab this replaces.
2026-08-06 18:45:18 -07:00
36 changed files with 1660 additions and 1154 deletions
+110
View File
@@ -3921,3 +3921,113 @@ One placement note that cost a debug cycle: the quickstart branch must return BE
reading the ones that exist, and the moments you most need the quickstart — no agents
yet, or the registry not answering — are exactly the ones those early returns swallow
it in.
## The rail shows ONE level, and the catalog is not an allow-list (fix/console-six)
Five defects the owner hit, and all five were one thing twice.
**Level 2 replaced level 1 in principle and was added to it in practice.** A product's
pages expanded INDENTED beneath its row while the whole catalog stayed painted below,
so two levels shared the screen. Worse, a PINNED product appeared twice — once under
Pinned carrying the pages, once in its category carrying nothing — and the `owns` rule
that picked which copy got the sub-list is the tell: a rule to decide which of two
identical rows is the real one means there should have been one row.
The level is a REPLACEMENT now and it is a function of the ROUTE alone. Inside a
product the rail is: the category (the way back up), the product as a HEADING, its
pages FLUSH, then `More in <category>` — its siblings, so sideways is still one click.
Nothing is remembered, so a reload, a deep link and Back cannot disagree with it.
Deleted: `NAV_PRODUCT_OPEN_PREF`, `productIsOpen`, `toggleProduct`, the `owns` rule, the
per-row expand chevron, and `SubRows`' inert accordion. `nav-accordion.ts``nav.ts`
(it holds the rail's view model, not one accordion).
**`SubRow` is now the only level-2 row.** The product-shell face had its own copy plus a
private `BILLING_SUBPAGE_ICON` map that was a strict subset of the shared `subpageIcon`.
Both gone.
**"Many products are missing" was a hard-coded list of 13 ids.** `filterBeta` kept only
`LAUNCH_PRODUCTS` for anyone who was not a superadmin — a deny-everything-not-named list
wearing the word "beta" (zero entries were actually stamped `beta: true`). A hanzo-org
user saw 13 of 185 products in 6 of 14 categories. Deleted whole: `LAUNCH_PRODUCTS`,
`isLaunchProduct`, `filterBeta`, `isBetaEntry`, `CatalogEntry.beta`, `useAppsBeta`
(`lib/products/beta.ts`), `beta-gate.test.ts`, and the `showBeta` argument threaded
through `visibleCatalog`, `searchDestinations`, the rail, the palette and the panel.
That left TWO answers to "may I see this product": the All-products panel said every
product is always available (`visibleCatalogByCategory(showAdmin, null)`) and the rail
still filtered by the org's entitlement set. Same catalog, two sizes, depending which
you asked. The `navCatalog` preference settles it: ON (default) the rail is everything
the viewer may SEE — permission only; OFF it narrows to the org's enabled set plus pins
and wherever you are. **Measured**: an org whose entitlement plane answers
`{enabled: []}` — which silently collapsed the rail to the always-on essentials — now
shows all 11 categories.
Permission (admin surfaces, brand scope) still holds in both states, so nothing on the
rail is a surface the viewer cannot open.
**One search.** The rail had its own text filter that narrowed only the rows it had
already drawn — a second search answering a smaller question, next to the header's ⌘K
palette and a third hand-rolled copy in the mobile drawer. All three are
`CommandSearchBox` now (`height` + `onOpen` are the only differences), so the rail's box
opens the one palette, which asks the WHOLE catalog from either level. `filtering` left
the rail entirely, and with it `categoryIsOpen`'s `ctx` argument.
**The two ends of the rail are peers.** `@hanzo/ui@8.0.56` ships an `OrgSwitcher` +
`UserMenu` pair built as peers, and it was surveyed: `OrgSwitcher` is better than ours
(debounced search, real paging — ours hard-codes page 0, so an admin could never reach
org #21 — and race protection), but its trigger is org-only and cannot say `Org /
project`; `UserMenu` hard-codes `placement: "bottom-end"` with no prop to change it, so
it CANNOT mount at the foot of a rail. Adopting one and not the other is not
convergence, so both are local and both are now ONE component: `components/ui/Menu.tsx`
— trigger, sheet and rows, worn by `ContextSwitcher` and `AccountMenu` alike.
- **The org name was replaced by the org's logo.** The trigger branched on `org.logo`
and rendered the image INSTEAD of the name; the name survived only in the aria-label.
The logo is the MARK now (`OrgMark` resolves logo-else-monogram), the name is the
label, always.
- `AccountMenu` was `@hanzo/iam`'s `UserMenu` — a second rendering system inside one
rail: raw `createElement`, an injected global `hz-iam-*` stylesheet, its own portal, a
28px CIRCLE with a one-letter initial against the org's 20px rounded square with two.
It is @hanzo/gui + `MenuRow` + `paper` now. Identity still comes from IAM
(`useSession`, `signOut`); only the drawing changed.
- **The balance row is gone from the menu**`SidebarWallet` sits one row below it,
reads the same `useCloudBalance`/`spendableCents`, links the same `config.payUrl`, and
adds the trial/prepaid split. The same number twice, the second time behind a click.
- ONE naming rule: `orgLabel(org)` in `account/org-state.ts`. The trigger titled the
slug and the rows printed it raw, so the control could read "Acme" over an active row
reading "acme". `SidebarBrand` used the raw slug too.
- `SidebarBrand` said "the tenant leads the chrome … never the house mark" and then
rendered `BrandMark` for any org without a logo — so one org wore the Hanzo glyph in
the collapsed rail and its own monogram in the expanded one. It matches its own rule.
**Two bugs `paper` and the ladder had been hiding.** `paper` declared `bordered: true`,
which leaves `borderWidth` at 0 — every anchored sheet in the console met the page with
no edge. And the menu took no z-layer, so on a phone (where it opens from inside the
account SHEET at `Z.modal`) it was measurable and unclickable; it is `Z.popover` now,
which is the layer the ladder documents for exactly this. `up` is a preference rather
than a promise (`allowFlip`/`stayInFrame`): at the foot of a desktop rail upward is
right, and 365px off the top of a phone it is not.
**Verification.** `tsc --noEmit` clean · `next build` clean · `vitest` **3340 passed /
8 skipped** (267 files) · Playwright **22/22** across `e2e/rail.spec.ts` (new, 6),
`account-menu.spec.ts` (4), `level-2-nav.spec.ts` (5) and `find-and-do.spec.ts` (7).
`rail.spec.ts` measures what only a browser can: that the level-2 pages sit FLUSH with
the row naming the level (x within 2px, the indentation defect), that nothing from
another category is painted beside them, that the product is NAMED once and linked
zero times, that both triggers share height/left-edge/type/weight and both carry a
chevron, and that the sheet is opaque at opacity 1 and fully on screen.
Two traps worth keeping: `getByLabel('Find an organization')` resolves to the
`role="search"` DIV wrapping `SearchInput` (which takes no `aria-label`), so `.fill()`
throws — use the placeholder. And typing at the PAGE (`keyboard.type`) puts text in a
React-Native-Web `Input`'s DOM without ever raising `onChangeText`: the box reads
"vector" while the list is still the unfiltered browse view, so a spec that then looks
for "Vector" finds it in the browse list and passes without the search having run. Fill
the input and assert where ENTER lands.
**Found, not fixed — for the palette lane.** The palette's visible list disagrees with
the list it acts on. Type `vector` and commit: Enter correctly opens `/vector`
(`items[sel]` comes from the ranked `destResults`), while the rows on screen are the
unfiltered `browseGroups` in catalog order with `Overview` first and `#cmdk-active` on
it. Reproduces via ⌘K, so it predates this work and is not caused by the rail's new
search box. `find-and-do.spec.ts` passes because it probes Enter, never DOM order.
+6 -4
View File
@@ -17,7 +17,9 @@
* against is a menu that is present in the DOM and unreadable — a library that
* paints with utility class names renders exactly that in this app, because
* Tailwind never scanned node_modules. An `expect(locator).toBeVisible()` would
* have passed on the broken build.
* have passed on the broken build. That is also why the menu is now the console's
* OWN `Menu` on @hanzo/gui rather than a second rendering system injecting its own
* global stylesheet: the rows are `MenuRow`, so they are located by ARIA role.
*/
import { test, expect, type Page } from '@playwright/test'
import { primeSession } from './_session'
@@ -152,7 +154,7 @@ test.describe('account control', () => {
expect(onTop).toBe(true)
// Rows are padded, tall enough to hit, and readable.
const rows = await menu.locator('.hz-iam-row').evaluateAll((els) =>
const rows = await menu.locator('[role=menuitem], [role=radio]').evaluateAll((els) =>
els.map((el) => {
const s = getComputedStyle(el)
return { pl: s.paddingLeft, h: el.getBoundingClientRect().height, color: s.color, text: (el.textContent ?? '').trim() }
@@ -166,7 +168,7 @@ test.describe('account control', () => {
}
// Hover is a real state — the switch-that-rendered-identical class of bug.
const first = menu.locator('.hz-iam-row').first()
const first = menu.locator('[role=menuitem], [role=radio]').first()
const atRest = await first.evaluate((el) => getComputedStyle(el).backgroundColor)
await first.hover()
expect(await first.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(atRest)
@@ -200,7 +202,7 @@ test.describe('account control', () => {
// Acme is nobody's membership — it exists only in the cross-tenant list an
// admin may search. A memberships-only switcher could not offer it at all.
await page.getByLabel('Find an organization').fill('acme')
await page.getByPlaceholder('Find an organization').fill('acme')
// `radiogroup`/`radio`, not `listbox`/`option`: @hanzo/gui's `role` union is
// React Native's a11y set, which carries `option` but NOT `listbox`.
const orgList = page.getByRole('radiogroup', { name: 'Organizations' })
+27 -21
View File
@@ -1,16 +1,17 @@
/**
* e2e: ONE level-2 nav.
*
* Clicking into a product must reveal ITS options rather than replacing the screen,
* and there must be exactly ONE such nav on screen — not the sidebar's level 2 AND a
* competing tab strip in the content, which is what `/models` used to do (eight items
* in the rail, four in the content, disagreeing on the index's own name).
* Clicking into a product must reveal ITS options, and there must be exactly ONE
* such nav on screen — not the sidebar's level 2 AND a competing tab strip in the
* content, which is what `/models` used to do (eight items in the rail, four in the
* content, disagreeing on the index's own name).
*
* "Rather than replacing the screen" is now literal on both axes: the product's
* sub-pages expand BENEATH its row and the rest of the catalog stays put. The rail
* used to swap itself for the product's sub-nav behind a "Back to all products"
* button, so these specs assert the other products are still there — that is the
* whole point of the change, and the part a future drill would silently undo.
* The RAIL owns level 2: inside a product it lists that product's pages, flush, with
* the rest of its category beneath them and the category itself as the way back up.
* It does not swap itself for a bare sub-nav behind a "Back to all products" button
* (nothing to move sideways to), and it does not indent the pages under the product's
* row while the whole catalog stays painted below (two levels at once, and the same
* product listed twice). These specs pin both failures shut.
*
* These are assertions only a browser can make. They read COMPUTED style and
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
@@ -82,11 +83,9 @@ test('desktop: the sidebar owns level 2 — the content strip is not a second na
const page = await ctx.newPage()
await open(page, '/models')
// The rail expanded Models in place — and did NOT swap itself for it.
// No dead-end drill: the way out is the category and the full catalog, both named.
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
// The rest of the catalog is still there — "All products" sits at the FOOT of the
// product list, so its presence proves the list was never swapped away. This is the
// assertion the drill could not have passed.
await expect(page.getByRole('button', { name: 'Back to AI' }).first()).toBeVisible()
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
@@ -119,7 +118,12 @@ test('phone: the strip carries level 2 where the sidebar is a drawer', async ({
const labels = await strip(page, 'models').getByRole('button').allInnerTexts()
// Routing is admin-only and this account is an ORG admin, not a global one — the
// one nav gates it, so a customer is never offered a surface they cannot open.
expect(labels).toEqual(['Catalog', 'Leaderboard', 'Blend', 'Settings', 'Status', 'Logs', 'Metrics'])
//
// The tail reads raw → summary: Logs, then Metrics, then Status LAST (the
// live-health verdict comes after the signals it is derived from). f6df104ec8
// reordered BASE_SUBPAGES and updated match-core.test.ts but not this spec, so it
// asserted the retired order and failed against every build from 8.5.75 on.
expect(labels).toEqual(['Catalog', 'Leaderboard', 'Blend', 'Settings', 'Logs', 'Metrics', 'Status'])
// The strip wraps rather than pushing the page sideways.
const scrolls = await page.evaluate(
@@ -176,8 +180,8 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/models')
// Models is still expanded with the same options — browser Back moved the ROUTE,
// and the rail followed it without collapsing what the user was looking at.
// Models still owns the rail with the same options — browser Back moved the ROUTE,
// and the rail is a function of the route, so it followed without losing the level.
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
@@ -187,10 +191,9 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
/**
* Every product that used to carry its own `const TABS` — the whole conversion, in
* one sweep. For each: the page renders, the rail expands it in place, and the
* content strip is present but PAINTS NOTHING at lg+. That is the "no second nav"
* invariant, and it is the thing that regresses the moment someone adds a tab bar
* back.
* one sweep. For each: the page renders, the rail carries its pages, and the content
* strip is present but PAINTS NOTHING at lg+. That is the "no second nav" invariant,
* and it is the thing that regresses the moment someone adds a tab bar back.
*/
const CONVERTED = [
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
@@ -199,6 +202,9 @@ const CONVERTED = [
] as const
test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
// Eighteen full page loads in one test — it lands within a hair of the 60s
// default and fails on the wrong side of it about as often as the right one.
test.slow()
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
@@ -211,7 +217,7 @@ test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
).toBe('none')
await expect(
page.getByRole('button', { name: 'Back to all products' }),
`${id}: the rail expands in place — it must never swap itself for one product`,
`${id}: the rail keeps a way out — never a bare drill behind one button`,
).toHaveCount(0)
}
+267
View File
@@ -0,0 +1,267 @@
/**
* e2e: the rail — its two levels, its two ends, and the one search.
*
* These are assertions only a browser can make. They read COMPUTED geometry and
* counts, because every defect here is a LAYOUT defect: a level that renders while
* the level above it is still painted, a name replaced by a picture, two triggers
* that are meant to be peers and are not. `toBeVisible()` resolves to display /
* visibility / box-size, which is the only honest test of "is this on screen".
*
* Run: BASE_URL=http://localhost:4123 npx playwright test rail
*/
import { test, expect, type Route, type Page } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { primeSession } from './_session'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
const SHOTS = join(process.cwd(), 'e2e-shots')
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|org|auth\/refresh)(\/|$|\?)/
/** A tenant that HAS uploaded a logo — the case that used to erase the org's name. */
const LOGO = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Crect width="24" height="24" fill="%237c5cff"/%3E%3C/svg%3E'
/**
* Every backend answers 401 except the org identity, which answers a real org
* WITH a logo and a display name. This spec is about the RAIL, not data, and an
* unauthorized read is the state every module already handles honestly.
*/
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
const json = (body: unknown, status = 200) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
if (url.pathname.startsWith('/auth/')) return json({ ok: true })
if (url.href.includes('get-organization')) {
return json({ status: 'ok', data: { name: 'hanzo', displayName: 'Hanzo AI', logo: LOGO } })
}
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return json({ error: 'Sign in to use Hanzo Cloud.' }, 401)
}
/** A hanzo-org user — an admin of their OWN org, NOT a platform super admin.
* This is the identity the whole catalog must be available to. */
const ACCOUNT = { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin', isAdmin: true }
async function open(page: Page, path: string) {
await page.route('**/*', mock)
await primeSession(page, ACCOUNT)
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
await page.locator('nav[aria-label="Products"]').first().waitFor({ state: 'attached', timeout: 30_000 })
await page.waitForTimeout(1500)
}
/** The persistent rail (the shell mounts the same nav three times; this is the
* first in document order, and every geometry check proves it is the painted one). */
const rail = (page: Page) => page.locator('nav[aria-label="Products"]').first()
const railRow = (page: Page, name: string) =>
rail(page).getByRole('button', { name, exact: true }).filter({ visible: true })
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('level 1 is the whole catalog — a hanzo-org user is not shown a dozen products', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
const page = await ctx.newPage()
await open(page, '/')
// The catalog used to be filtered to a hard-coded 13-id launch list, so a
// customer saw six categories. Every category the brand admits is here now.
const text = await rail(page).innerText()
for (const category of ['AI', 'Compute', 'Data', 'Network', 'Security', 'Observe', 'Platform', 'Dev', 'Web3', 'Apps']) {
expect(text, `${category} is a section of the catalog`).toContain(category)
}
// Products from categories the launch list erased entirely.
for (const label of ['Vector', 'Functions', 'Gateway', 'Fine-tuning', 'Projects']) {
await expect(railRow(page, label).first(), `${label} is reachable`).toBeVisible()
}
await page.screenshot({ path: join(SHOTS, 'rail-level-1.png') })
await ctx.close()
})
test('level 2 REPLACES level 1 — the pages are flush and the catalog is not underneath', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
const page = await ctx.newPage()
await open(page, '/models')
// The product's own pages are the list.
for (const label of ['Catalog', 'Leaderboard', 'Blend']) {
await expect(railRow(page, label).first(), `${label} is a rail row`).toBeVisible()
}
// Its category siblings follow them, so moving sideways is one click.
await expect(rail(page)).toContainText('More in AI')
await expect(railRow(page, 'Playground').first()).toBeVisible()
// …and NOTHING from another category is painted: level 1 is gone, not pushed down.
for (const label of ['Vector', 'Functions', 'Gateway']) {
expect(await railRow(page, label).count(), `${label} belongs to level 1`).toBe(0)
}
// Exactly ONE mention of the product. It used to appear TWICE — once under
// Pinned carrying the indented pages, once in its category carrying nothing —
// so the rail showed the same product in two places, one of them dead.
const lines = (await rail(page).innerText()).split('\n').map((l) => l.trim())
expect(lines.filter((l) => l === 'Models'), 'Models is named once').toHaveLength(1)
// …and it is a HEADING, not a link: its index page is the first row beneath it,
// so a second way to the same page would be the duplication this level removes.
expect(await railRow(page, 'Models').count(), 'the name is not a second link').toBe(0)
// The pages are FLUSH with the row that names the level, not indented under it.
const pages = await Promise.all(
['Catalog', 'Leaderboard', 'Blend'].map(async (l) => (await railRow(page, l).first().boundingBox())!.x),
)
const back = (await railRow(page, 'Back to AI').first().boundingBox())!.x
for (const [i, x] of pages.entries()) {
expect(Math.abs(x - back), `page ${i} is flush with the level, not indented`).toBeLessThanOrEqual(2)
}
await page.screenshot({ path: join(SHOTS, 'rail-level-2.png') })
// The way back up is the category the product sits in.
await railRow(page, 'Back to AI').first().click()
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/category/ai')
// …and the rail is level 1 again.
await expect(railRow(page, 'Vector').first()).toBeVisible()
await ctx.close()
})
test('the org switcher shows the org NAME even when the org has a logo', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
const page = await ctx.newPage()
await open(page, '/')
const org = page.getByTestId('switcher-context').first()
// The logo took the name's slot; the name survived only in the aria-label.
await expect(org).toContainText('Hanzo AI')
// The logo is the MARK beside it, not a replacement for it.
await expect(org.locator('img')).toBeVisible()
await org.click()
await page.locator('[role=menu]').first().waitFor()
await page.waitForTimeout(600)
await page.screenshot({ path: join(SHOTS, 'rail-org-name.png'), animations: 'disabled' })
await ctx.close()
})
test('the two ends of the rail are peers — same box, same mark, same type', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
const page = await ctx.newPage()
await open(page, '/')
const measure = (id: string) =>
page.getByTestId(id).first().evaluate((el) => {
const s = getComputedStyle(el)
const mark = el.querySelector('img, [class*="OrgMark"], span, div')
return {
height: el.getBoundingClientRect().height,
x: el.getBoundingClientRect().x,
font: s.fontSize,
weight: s.fontWeight,
// The chevron: both must carry one, or one reads as a caption.
svgs: el.querySelectorAll('svg').length,
markH: mark ? Math.round(mark.getBoundingClientRect().height) : 0,
}
})
const org = await measure('switcher-context')
const account = await measure('nav-user')
expect(account.height, 'same height').toBe(org.height)
expect(account.x, 'same left edge').toBe(org.x)
expect(account.font, 'same type size').toBe(org.font)
expect(account.weight, 'same type weight').toBe(org.weight)
expect(account.svgs > 0 && org.svgs > 0, 'both carry a chevron').toBe(true)
// The account is at the FOOT, the org at the HEAD — peers, not a stack.
const orgY = (await page.getByTestId('switcher-context').first().boundingBox())!.y
const accY = (await page.getByTestId('nav-user').first().boundingBox())!.y
expect(accY).toBeGreaterThan(orgY)
await page.screenshot({ path: join(SHOTS, 'rail-switchers.png') })
// Both open onto the same sheet, and the account's opens UPWARD so it stays
// on screen from the foot of the rail.
await page.getByTestId('nav-user').first().click()
const menu = page.locator('[role=menu]').first()
await menu.waitFor()
await page.waitForTimeout(600)
const box = (await menu.boundingBox())!
expect(box.y, 'the account sheet opens upward, fully on screen').toBeGreaterThanOrEqual(0)
expect(box.y + box.height).toBeLessThanOrEqual(1001)
// It PAINTS: an opaque sheet, not a transparent stack the rail reads through.
const paint = await menu.evaluate((el) => {
const s = getComputedStyle(el)
return { bg: s.backgroundColor, opacity: s.opacity }
})
expect(paint.bg).not.toBe('rgba(0, 0, 0, 0)')
expect(Number(paint.opacity)).toBe(1)
await page.screenshot({ path: join(SHOTS, 'rail-account-menu.png'), animations: 'disabled' })
await ctx.close()
})
test('search from the rail reaches a product the rail is not currently showing', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
const page = await ctx.newPage()
// Inside a product, so the rail is level 2 and the catalog is put away.
await open(page, '/models')
expect(await railRow(page, 'Vector').count(), 'Vector is not on the rail here').toBe(0)
// The rail's search box is the SAME palette the header opens — one search.
await rail(page).getByText('Search or jump to…').first().click()
await page.waitForTimeout(900)
// Fill the palette's OWN input, then commit — where the query TAKES you is the
// honest probe. Merely finding the word "Vector" on screen proves nothing: with an
// empty query the palette browses the whole catalog, so "Vector" is already there
// and a spec that looks for it passes without the search ever having run.
const box = page.getByPlaceholder(/Search apps and commands/i).filter({ visible: true }).first()
await box.fill('vector')
await expect(page.locator('#cmdk-active').first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'rail-search.png'), animations: 'disabled' })
await page.keyboard.press('Enter')
await expect.poll(() => new URL(page.url()).pathname, { timeout: 15_000 }).toBe('/vector')
// …and the rail followed: it is now Vector's level 2, from a product that was
// nowhere on the rail a moment ago.
await page.waitForTimeout(1500)
await expect(rail(page)).toContainText('More in Data')
await page.screenshot({ path: join(SHOTS, 'rail-search-landed.png'), animations: 'disabled' })
await ctx.close()
})
test('the settings toggle decides whether the rail lists the catalog', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
const page = await ctx.newPage()
await open(page, '/profile')
const label = page.getByText('Show every product').first()
await expect(label).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'rail-toggle.png') })
// ON by default: the rail carries the catalog — here, the rest of this
// product's category beneath its own pages.
await expect(rail(page)).toContainText('More in Settings')
await expect(railRow(page, 'Members').first()).toBeVisible()
// Turn it off — the rail keeps this product's pages and the pins, nothing else.
const toggle = page.getByRole('switch').first()
await expect(toggle).toBeVisible()
await toggle.click()
await page.waitForTimeout(900)
await expect(rail(page), 'the catalog is put away').not.toContainText('More in Settings')
await expect(railRow(page, 'Account').first(), 'this product keeps its pages').toBeVisible()
await page.screenshot({ path: join(SHOTS, 'rail-toggle-off.png') })
// Back at level 1 the catalog is put away there too — the pins remain.
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(1500)
expect(await railRow(page, 'Vector').count(), 'no catalog at level 1 either').toBe(0)
await expect(railRow(page, 'Models').first(), 'the pins stay').toBeVisible()
await ctx.close()
})
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "@hanzo/console",
"version": "8.5.75",
"version": "8.5.83",
"packageManager": "pnpm@11.17.0",
"private": true,
"license": "MIT OR Apache-2.0",
"author": "Hanzo AI <dev@hanzo.ai>",
"description": "Hanzo Cloud Console unified admin console for Hanzo Cloud and all cloud products.",
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
"scripts": {
"dev": "next dev -p 4000",
"build": "next build",
+104 -58
View File
@@ -2,83 +2,129 @@
/**
* The account control — WHO you are: identity, your team, your personal
* settings, what you have left to spend, and the way out. ONE control, at the
* foot of the rail.
* settings, the theme, and the way out. ONE control, at the foot of the rail.
*
* It deliberately does NOT switch tenant. Org and project are one question —
* WHERE you are — and they are answered together by `ContextSwitcher` at the
* top-left, beside the tenant's own mark. Handing this menu an `orgState` too
* would put the org in two corners again, which is the exact confusion the
* condensed switcher removes. The cross-tenant reach, the admin-gated org list
* and the single `org-scope.switchOrg` money seam all moved there intact; there
* is still exactly one org switch in the app.
* top-left, beside the tenant's own mark. Handing this menu an org too would put
* the org in two corners again, which is the exact confusion the condensed
* switcher removes. The cross-tenant reach, the admin-gated org list and the
* single `org-scope.switchOrg` money seam all live there; there is still exactly
* one org switch in the app.
*
* It is `@hanzo/iam`'s `UserMenu`, the same component hanzo.chat mounts, so the
* identity and the behaviour (click-away, Escape, close-before-navigate, never a
* raw uuid) are shared rather than rebuilt. This file is the ADAPTER —
* everything the console knows that the SDK does not:
* It is the SAME `Menu` the context switcher wears — same height, same mark, same
* type, same chevron, same sheet — so the two ends of the rail are peers. It used
* to be `@hanzo/iam`'s `UserMenu`: a second rendering system inside one rail,
* drawing raw DOM through an injected global stylesheet into its own portal, with
* a 28px circle and a one-letter initial against the org's rounded-square,
* two-letter mark. Identity still comes from IAM (`useSession`, `signOut`) — what
* changed is that the console draws its own rail with its own primitives, once.
*
* - THEME. The console themes through `@hanzogui/next-theme` (which drives the
* Gui tree). That is adapted into the menu's shape rather than mounting IAM's
* own theme hook beside it — one theme system, not two.
*
* - BRAND. The strip at the foot wears THIS host's brand. Passing nothing would
* paint a Hanzo mark on a Lux or Zoo console.
* The BALANCE is not here. `SidebarWallet` sits one row below this control and
* shows the same number from the same hook, plus the trial/prepaid split and a
* top-up button — so the copy in here was the same fact twice, the second time
* behind a click.
*/
import { useMemo } from 'react'
import { UserMenu, type UserTheme } from '@hanzo/iam/react'
import { useRouter } from 'next/navigation'
import { Text, YStack } from '@hanzo/gui'
import { BookOpen, LogOut, Receipt, Users, UserRound } from '@hanzogui/lucide-icons-2'
import { useThemeSetting } from '@hanzogui/next-theme'
import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
import { MenuRow } from '~/components/ui/MenuRow'
import { Menu, MenuLabel, MenuRule } from '~/components/ui/Menu'
/** `system` is a real choice, and the console's provider already understands it. */
const THEMES = [
{ mode: 'light', label: 'Light' },
{ mode: 'dark', label: 'Dark' },
{ mode: 'system', label: 'Sync with system' },
] as const
export function AccountMenu() {
const router = useRouter()
const { account, signOut } = useSession()
const { balance } = useCloudBalance()
const { current, resolvedTheme, set } = useThemeSetting()
// `system` is a real choice, and the console's provider already understands it.
const theme: UserTheme = useMemo(
() => ({
mode: (current === 'light' || current === 'dark' ? current : 'system') as UserTheme['mode'],
resolved: (resolvedTheme ?? current) === 'light' ? 'light' : 'dark',
setMode: (mode) => set(mode),
}),
[current, resolvedTheme, set],
)
const { current, set } = useThemeSetting()
if (!account) return null
const cents = spendableCents(balance)
// Never a fabricated "User" — a name nobody chose reads as a bug to the person
// it names. The email's local part is a real name; the account name is a real name.
const name = account.displayName?.trim() || account.name
const mode = current === 'light' || current === 'dark' ? current : 'system'
return (
<UserMenu
align="up"
identity={{
name,
email: account.email ?? null,
initials: (name || '?').slice(0, 1).toUpperCase(),
avatarUrl: account.avatar || null,
<Menu
mark={{ name, logo: account.avatar || undefined }}
label={name}
aria={`Account — ${name}`}
testId="nav-user"
up
>
{(close) => {
const go = (href: string) => () => {
close()
router.push(href)
}
return (
<YStack gap="$0.5">
<YStack px="$2" py="$1.5">
<Text fontSize="$2" color="$color12" numberOfLines={1}>
{name}
</Text>
{account.email ? (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{account.email}
</Text>
) : null}
</YStack>
<MenuRule />
<MenuRow label="Profile" icon={<UserRound size={14} />} onPress={go('/profile')} />
<MenuRow label="Billing & usage" icon={<Receipt size={14} />} onPress={go('/billing')} />
{/* Your people, beside your own settings — the other half of "who am I". */}
<MenuRow label="Members" icon={<Users size={14} />} onPress={go('/team')} />
<MenuRow
label="Documentation"
icon={<BookOpen size={14} />}
onPress={() => {
close()
window.open(config.docsUrl, '_blank', 'noopener,noreferrer')
}}
/>
<MenuRule />
<MenuLabel>Theme</MenuLabel>
<YStack role="radiogroup" aria-label="Theme" gap="$0.5">
{THEMES.map((t) => (
<MenuRow
key={t.mode}
label={t.label}
active={mode === t.mode}
onPress={() => {
close()
set(t.mode)
}}
/>
))}
</YStack>
<MenuRule />
<MenuRow
label="Sign out"
icon={<LogOut size={14} />}
onPress={() => {
close()
void signOut()
}}
/>
</YStack>
)
}}
isAuthenticated
isLoading={false}
onSignOut={() => void signOut()}
theme={theme}
settingsUrl="/profile"
usageUrl="/billing"
usageLabel="Billing & usage"
// Only shown when the backend actually reported a balance — never a fabricated $0.
balance={cents === null ? undefined : { amountUsd: cents / 100, topUpUrl: config.payUrl }}
items={[
// Your people, beside your own settings — the other half of "who am I".
// Choosing a DIFFERENT tenant is a different question and lives in the
// top-left context switcher, so this menu never re-scopes the console.
{ label: 'Members', href: '/team' },
{ label: 'Documentation', href: config.docsUrl, external: true, separatorBefore: true },
]}
brand={{ name: config.brandName }}
/>
</Menu>
)
}
+1 -3
View File
@@ -20,7 +20,6 @@ import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
import { useAppsBeta } from '~/lib/products/beta'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { usePins, useProductColors } from '~/lib/products/pins'
import { openProduct } from '~/lib/products/open'
@@ -190,8 +189,7 @@ export function AddProductPanel() {
// Source = the FULL catalog the viewer may see (ungated → both pinned and unpinned
// appear), grouped by category.
const showBeta = useAppsBeta(showAdmin)
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null, showBeta), [showAdmin, showBeta])
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null), [showAdmin])
// Literal, case-insensitive substring match over label/description/id — NOT a
// compiled RegExp of user input.
+16 -7
View File
@@ -77,7 +77,6 @@ import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } fr
import { searchDestinations, type Destination } from '~/lib/products/search'
import { DEFAULT_GROUP_LABEL, pinnedFirst } from '~/lib/products/pins-core'
import { usePins, useProductColors } from '~/lib/products/pins'
import { useAppsBeta } from '~/lib/products/beta'
import { ProductIcon } from '~/components/ui/ProductIcon'
import { openProduct } from '~/lib/products/open'
import { currentOrg, switchOrg } from '~/lib/org-scope'
@@ -214,6 +213,7 @@ function CatalogRow({
return (
<XStack
className="hz-row-pin"
data-testid="palette-hit"
onPress={onPress}
cursor="pointer"
items="center"
@@ -279,6 +279,7 @@ function DestinationRow({
const Icon = subpage.icon ?? entry.icon
return (
<XStack
data-testid="palette-hit"
onPress={onPress}
cursor="pointer"
items="center"
@@ -365,7 +366,6 @@ function PaletteDialog({
const router = useRouter()
const { signOut } = useSession()
const showAdmin = useIsSuperAdmin()
const showBeta = useAppsBeta(showAdmin)
const { colorOf } = useProductColors()
const pins = usePins()
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
@@ -441,7 +441,7 @@ function PaletteDialog({
// a search, so the ranked branch is left strictly alone.
const destResults = useMemo(() => {
if (mode !== 'catalog') return []
const found = searchDestinations(query, showAdmin, null, showBeta)
const found = searchDestinations(query, showAdmin, null)
if (sub) return found.slice(0, 50)
return pinnedFirst(found, (d) => (d.kind === 'product' ? d.entry.id : ''), pins.pinnedIds)
}, [mode, query, sub, showAdmin, pins.pinnedIds])
@@ -822,17 +822,26 @@ export function Palette({ children }: { children: ReactNode }) {
)
}
/** Header trigger — a search box that opens the palette. */
export function CommandSearchBox() {
/**
* THE search box — the one control that opens the palette, wherever a surface wants
* to offer search. The header, the sidebar rail and the mobile drawer all mount this
* one component; each used to draw its own box, which is three searches for one
* question. `onOpen` lets a surface that is itself dismissible (the drawer) step out
* of the way first.
*/
export function CommandSearchBox({ height = 36, onOpen }: { height?: number; onOpen?: () => void } = {}) {
const { open } = useCommandPalette()
return (
<XStack
onPress={open}
onPress={() => {
onOpen?.()
open()
}}
cursor="pointer"
items="center"
gap="$2"
px="$3"
height={36}
height={height}
flex={1}
maxW={420}
bg="$color2"
+102 -144
View File
@@ -10,10 +10,14 @@
* project are one question — which tenant, and which slice of it — so they are
* one control, and it sits with the org mark that already anchors the top-left.
*
* The ACCOUNT keeps the other question ("who am I": identity, team, personal
* settings, the way out) at the foot of the rail. The NETWORK stays its own
* control in the top-right, because it is a global MODE rather than a place —
* and its tier dot is a destructive-environment guard, not decoration.
* The ACCOUNT keeps the other question ("who am I") at the foot of the rail, and
* wears the SAME `Menu` — same height, same mark, same type, same chevron — so the
* two ends of the rail read as two halves of one identity.
*
* The org's NAME always shows. It used to be replaced by the org's logo whenever
* IAM carried one, which left a tenant with a logo looking at a picture and no
* name at all — the name survived only in the aria-label. The logo is the MARK now
* (that is what `OrgMark` is for), and the name is the label, always.
*
* There is still exactly ONE org switch. `switchOrg` is passed by reference from
* `~/lib/org-scope` (the seam that persists the scope and reloads so every
@@ -26,20 +30,19 @@
*/
import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
import { Text, YStack } from '@hanzo/gui'
import { FolderGit2, Plus, SlidersHorizontal } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { useOrgIdentity } from '~/components/ui/BrandLogo'
import { OrgMark } from '@hanzo/ui/product'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { IamAdminApi, type Organization } from '~/lib/api'
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
import { contextLabel, orgLabel, scopedOrgRow } from '~/lib/account/org-state'
import { MenuRow } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
import { SearchInput } from '@hanzo/ui/product'
import { Menu, MenuLabel, MenuRule } from '~/components/ui/Menu'
import { OrgMark, SearchInput } from '@hanzo/ui/product'
export function ContextSwitcher() {
const router = useRouter()
@@ -47,13 +50,12 @@ export function ContextSwitcher() {
const scoped = currentOrg()
const isSuperAdmin = useIsSuperAdmin()
const { scope, projects, loadingProjects, selectProject } = useScope()
const [open, setOpen] = useState(false)
const [orgs, setOrgs] = useState<Organization[] | null>(null)
const [query, setQuery] = useState('')
// IAM's display name when it has one; otherwise the slug, titled the same way
// `scopedOrgRow` titles it — one rule, so the trigger and the list agree.
const orgLabel = org.displayName || titleCase(org.name || scoped)
// ONE naming rule — `orgLabel` — so the trigger and the row for the very same
// org can never read differently ("Acme" above, "acme" below).
const name = orgLabel(org.displayName ? org : { ...org, name: org.name || scoped })
// The cross-tenant list is admin-gated at the proxy; a regular user would 403
// it, so they are never asked to — their own org is the honest answer. An admin
@@ -68,14 +70,6 @@ export function ContextSwitcher() {
[isSuperAdmin, scoped],
)
const onOpenChange = useCallback(
(next: boolean) => {
setOpen(next)
if (next && orgs === null) void loadOrgs('')
},
[orgs, loadOrgs],
)
const search = useCallback(
(q: string) => {
setQuery(q)
@@ -84,141 +78,105 @@ export function ContextSwitcher() {
[loadOrgs],
)
const pick = useCallback(
(fn: () => void) => () => {
setOpen(false)
fn()
},
[],
)
const orgRows = useMemo(() => orgs ?? [], [orgs])
return (
<Popover open={open} onOpenChange={onOpenChange} placement="bottom-start">
<Popover.Trigger asChild>
<Button
size="$3"
chromeless
justify="flex-start"
px="$2"
data-testid="switcher-context"
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
>
{org.logo ? (
// The org's own logo IS the label — the uploaded mark takes the
// slot the name held, height-capped to the row so any aspect fits.
// A scoped project keeps its text beside it; the full text stays
// in the aria-label either way. Arbitrary tenant URL/data URL, so
// a raw <img> (next/image would need a per-tenant remote
// allow-list) — same call BrandLogo makes.
<XStack items="center" gap="$2" flex={1} minW={0}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={org.logo}
alt={orgLabel}
style={{ height: 22, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
/>
{scope.project ? (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
/ {scope.project}
</Text>
) : null}
</XStack>
) : (
// No uploaded logo: lead with the org's shared OrgMark (its monogram —
// the SAME mark SidebarBrand and the account widget wear), so the switcher
// is never a bare name. White-label safe: OrgMark is the tenant's OWN mark
// (the org's IAM logo when set, else its monogram), never the house glyph.
<XStack items="center" gap="$2" flex={1} minW={0}>
<OrgMark org={org} size={20} />
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{contextLabel(orgLabel, scope.project)}
</Text>
</XStack>
)}
</Button>
</Popover.Trigger>
<Menu
mark={org}
label={contextLabel(name, scope.project)}
aria={`Organization and project — ${contextLabel(name, scope.project)}`}
testId="switcher-context"
onOpen={() => {
if (orgs === null) void loadOrgs('')
}}
>
{(close) => {
const pick = (fn: () => void) => () => {
close()
fn()
}
return (
<YStack gap="$0.5">
<MenuLabel>Organization</MenuLabel>
<Popover.Content {...paper} p="$2" width={280}>
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Organization
</Text>
{/* Admins only: the cross-tenant list is server-paged and longer than
one page, so reaching a tenant nobody is a member of means SEARCHING
it, not scrolling. A regular user has one org and no field. */}
{isSuperAdmin ? (
<YStack px="$1" pb="$1">
{/* A search landmark names the control for assistive tech — the shared
SearchInput has no accessible-name prop of its own. */}
<div role="search" aria-label="Find an organization">
<SearchInput value={query} onChange={search} placeholder="Find an organization" name="org" />
</div>
</YStack>
) : null}
{/* Admins only: the cross-tenant list is server-paged and longer than
one page, so reaching a tenant nobody is a member of means SEARCHING
it, not scrolling. A regular user has one org and no field. */}
{isSuperAdmin ? (
<YStack px="$1" pb="$1">
{/* A search landmark names the control for assistive tech — the shared
SearchInput has no accessible-name prop of its own. */}
<div role="search" aria-label="Find an organization">
<SearchInput value={query} onChange={search} placeholder="Find an organization" name="org" />
</div>
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
{orgRows.map((o) => (
<MenuRow
key={o.name}
label={orgLabel(o)}
icon={<OrgMark org={o} size={18} />}
active={scoped === o.name}
onPress={pick(() => {
if (o.name !== scoped) switchOrg(o.name)
})}
/>
))}
</YStack>
) : null}
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
{orgRows.map((o) => (
<MenuRow
key={o.name}
label={o.displayName || o.name}
active={scoped === o.name}
onPress={pick(() => {
if (o.name !== scoped) switchOrg(o.name)
})}
/>
))}
</YStack>
{orgRows.length === 0 ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
{orgs === null ? 'Loading…' : 'No organization matches that.'}
</Text>
) : null}
{orgRows.length === 0 ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
{orgs === null ? 'Loading…' : 'No organization matches that.'}
</Text>
) : null}
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
<XStack height={1} bg="$borderColor" my="$1" />
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Project
</Text>
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
{/* Org-level scope — no X-Project-Id sent. */}
<MenuRow
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={pick(() => selectProject(undefined))}
label="Organization settings"
icon={<SlidersHorizontal size={14} />}
onPress={pick(() => router.push('/settings/branding'))}
/>
{projects.map((p) => (
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
<MenuRule />
<MenuLabel>Project</MenuLabel>
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
{/* Org-level scope — no X-Project-Id sent. */}
<MenuRow
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={pick(() => selectProject(p.name))}
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={pick(() => selectProject(undefined))}
/>
))}
{projects.map((p) => (
<MenuRow
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={pick(() => selectProject(p.name))}
/>
))}
</YStack>
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<MenuRow
label="New project"
icon={<FolderGit2 size={14} />}
onPress={pick(() => router.push('/projects'))}
/>
</YStack>
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<MenuRow
label="New project"
icon={<FolderGit2 size={14} />}
onPress={pick(() => router.push('/projects'))}
/>
</YStack>
</Popover.Content>
</Popover>
)
}}
</Menu>
)
}
+61 -19
View File
@@ -10,10 +10,11 @@
* to that org (X-Org-Id) and drops into it; the sidebar "Home" affordance
* ({@link leaveOrg}) returns here and de-scopes.
*
* Data source mirrors {@link OrgSwitcher}: a global admin lists all orgs via the
* gated `/admin/iam` proxy; a tenant (who 403s that list) sees just their own org,
* synthesized from the session — so the picker is always honest and never fabricates
* an org. All decisions (sort, filter, paginate, card view-model) live in the pure
* Data source: a global admin lists every org via the gated `/admin/iam` proxy;
* everyone else lists the orgs they are a MEMBER of — their membership rows
* unioned with their home org, each read as its own record so a card carries the
* ORG's name and logo. It never fabricates an org, and it never labels one with
* the signed-in person. All decisions (sort, filter, paginate, card view-model) live in the pure
* `org-picker/logic.ts`; this file is a thin render of it with honest loading /
* empty / error states.
*/
@@ -25,7 +26,7 @@ import { getBrand } from '~/lib/branding/brands'
import { useSession } from '~/lib/auth/session'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { enterOrg } from '~/lib/org-scope'
import { IamAdminApi, type Organization } from '~/lib/api'
import { IamAdminApi, MembershipApi, TeamApi, orgNamesFor, type Organization } from '~/lib/api'
import { BrandMark } from '~/components/ui/BrandLogo'
import { OrgOnboarding } from '~/components/OrgOnboarding'
import { PAGE_SIZE, pickerView, type OrgCard, type PickerContext } from '~/components/org-picker/logic'
@@ -109,30 +110,71 @@ export function OrgPicker() {
const owner = account?.owner ?? ''
const [orgs, setOrgs] = useState<Organization[] | null>(null)
// org -> the caller's role in it, straight from the membership rows. The card
// states the role it was GRANTED rather than one inferred from the account.
const [roles, setRoles] = useState<Record<string, string>>({})
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [page, setPage] = useState(1)
const [creating, setCreating] = useState(false)
// The caller's OWN org, synthesized from the session — the single org that IS a
// tenant's identity, and the honest fallback if the cross-tenant list can't load.
// The caller's HOME org, named by its own slug — the last-resort fallback when
// even the membership read fails.
//
// Its displayName used to be `account.displayName`, which is the signed-in
// PERSON. An org card then announced a human ("Dave Lorenzini") where the
// organization belongs, and the monogram it derived was the person's initials
// rather than the org's mark. A person is not an org; when the org's own row
// cannot be read, its slug is the honest label.
const ownOrgOnly = useMemo<Organization[]>(
() =>
owner
? [{ owner: 'admin', name: owner, displayName: account?.displayName?.trim() || titleCase(owner) } as Organization]
: [],
[owner, account?.displayName],
() => (owner ? [{ owner: 'admin', name: owner, displayName: titleCase(owner) } as Organization] : []),
[owner],
)
// Load the caller's visible orgs. A global admin gets the full cross-tenant list
// (paged large, then this component client-paginates); a tenant 403s that list, so
// it sees just its own org — honest, never fabricated.
// (paged large, then this component client-paginates). Everyone else gets the
// orgs they are a MEMBER of — their memberships unioned with their home org,
// each read as its own row so the card shows the ORG's name and logo.
//
// This used to be one card synthesized from the session, which could never show
// a second org: a customer with a workspace besides their home tenant simply did
// not see it. Reading each row is also what puts a real logo on the card; IAM
// authorizes a member to read the orgs they belong to (v1.34.26), so the fetch
// that used to 403 for a tenant now answers.
useEffect(() => {
if (!owner) return
let live = true
if (!isSuperAdmin) {
setOrgs(ownOrgOnly)
return
const me = account?.name ? `${owner}/${account.name}` : ''
if (!me) {
setOrgs(ownOrgOnly)
return
}
MembershipApi.mine(me)
.then((rows) => {
if (live) setRoles(Object.fromEntries(rows.map((m) => [m.org, m.role])))
return orgNamesFor(owner, rows)
})
.then((names) =>
// One read per org, and a row that cannot be read degrades to its slug
// rather than dropping the org off a list the person is entitled to see.
Promise.all(
names.map((name) =>
TeamApi.organization(name).catch(
() => ({ owner: 'admin', name, displayName: titleCase(name) }) as Organization,
),
),
),
)
.then((rows) => {
if (live) setOrgs(rows)
})
.catch(() => {
if (live) setOrgs(ownOrgOnly)
})
return () => {
live = false
}
}
setOrgs(null)
setError(null)
@@ -151,11 +193,11 @@ export function OrgPicker() {
return () => {
live = false
}
}, [owner, isSuperAdmin, ownOrgOnly])
}, [owner, isSuperAdmin, ownOrgOnly, account?.name])
const ctx: PickerContext = useMemo(
() => ({ ownOrg: owner, isSuperAdmin, callerIsAdmin: Boolean(account?.isAdmin) }),
[owner, isSuperAdmin, account?.isAdmin],
() => ({ ownOrg: owner, isSuperAdmin, callerIsAdmin: Boolean(account?.isAdmin), roles }),
[owner, isSuperAdmin, account?.isAdmin, roles],
)
const view = useMemo(
+14 -5
View File
@@ -31,6 +31,7 @@ import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { useOrgIdentity } from '~/components/ui/BrandLogo'
import { orgLabel } from '~/lib/account/org-state'
import { Z } from '~/lib/z'
import { OrgMark } from '@hanzo/ui/product'
@@ -113,7 +114,9 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
// The tenant leads the chrome: its own logo when set, else its monogram — never
// the house mark. `useOrgIdentity` is the ONE cached org-identity source.
const org = useOrgIdentity()
const orgLabel = org.displayName || org.name
// ONE naming rule, shared with the context switcher, so the collapsed rail and
// the expanded one cannot call the same org two different things.
const label = orgLabel(org)
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
const go = useCallback(
@@ -149,8 +152,8 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
onClick={() => go('/')}
onContextMenu={onContextMenu}
role="link"
aria-label={`${orgLabel} — home (right-click for brand menu)`}
title={orgLabel}
aria-label={`${label} — home (right-click for brand menu)`}
title={label}
style={{
display: 'flex',
alignItems: 'center',
@@ -161,8 +164,14 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
color: 'var(--color12)',
}}
>
{/* A logo may be a wordmark, so it is allowed to run wide; the monogram
stays the square tile the account avatar wears.
{/* The TENANT's mark, always — its uploaded logo when it has one, else its
own monogram. It used to fall back to the HOST's glyph, so an org with
no logo wore the Hanzo mark here and its own initials in the switcher:
one org, two marks, depending on whether the rail was collapsed.
`OrgMark` already resolves logo-else-monogram, which is the whole rule.
A logo may be a wordmark, so it is allowed to run wide; the monogram
stays the square tile the account mark wears.
`data-monogram`: OrgMark is a DISTRIBUTED component that sizes its
monogram glyph proportionally to its tile, so it paints text off the
+33
View File
@@ -182,3 +182,36 @@ describe('pickerView — filter → sort → paginate → cards', () => {
expect(p2.hasMore).toBe(false)
})
})
// ONE PERSON, MANY ORGS — and each card states the role it was actually granted.
//
// roleFor's last branch used to carry a comment calling itself unreachable
// ("a non-global-admin only ever sees their own org"). The picker now lists
// memberships, so that branch runs for every joined org — and it answered
// "Member" for all of them, including one the person administers. Measured
// live: dave, admin of maxpower, read "Member" on the maxpower card.
describe('roleFor — the membership is the truth once you can see more than one org', () => {
const org = (name: string) => ({ owner: 'admin', name }) as Organization
const base = { ownOrg: 'hanzo', isSuperAdmin: false, callerIsAdmin: false }
it('states the granted role for a joined org, not a guess', () => {
expect(roleFor(org('maxpower'), { ...base, roles: { maxpower: 'admin' } })).toBe('Admin')
expect(roleFor(org('acme'), { ...base, roles: { acme: 'owner' } })).toBe('Owner')
expect(roleFor(org('other'), { ...base, roles: { other: 'member' } })).toBe('Member')
})
it('prefers the membership over the own-org inference', () => {
// Their home org, where the account flag says plain member but the
// membership says admin — the membership is the grant.
expect(roleFor(org('hanzo'), { ...base, roles: { hanzo: 'admin' } })).toBe('Admin')
})
it('falls back to the own-org inference when no membership row exists', () => {
expect(roleFor(org('hanzo'), base)).toBe('Member')
expect(roleFor(org('hanzo'), { ...base, callerIsAdmin: true })).toBe('Admin')
})
it('still calls a super admin viewing another tenant what they are', () => {
expect(roleFor(org('maxpower'), { ...base, isSuperAdmin: true })).toBe('Super admin')
})
})
+19 -5
View File
@@ -44,6 +44,12 @@ export type PickerContext = {
isSuperAdmin: boolean
/** Whether the caller is an admin of their OWN org (`account.isAdmin`). */
callerIsAdmin: boolean
/**
* The caller's role in each org they hold a MEMBERSHIP in (`org -> role`),
* which is the only place the truth lives once a person can see more than one
* org. Optional so a caller that has not loaded memberships still renders.
*/
roles?: Record<string, string>
}
/** The full picker view for a query + page — everything the UI renders. */
@@ -79,16 +85,24 @@ export function initialsOf(org: Organization): string {
/**
* The caller's HONEST role in this org — derived from real context, never guessed:
* - super admin viewing another org → "Super admin" (masquerade access)
* - the caller's own org, they admin it"Admin"
* - the caller's own org otherwise → "Member"
* - super admin viewing their own org → "Admin"
* - an org they hold a MEMBERSHIP inthat membership's role
* - the caller's own org → "Admin" when they admin it, else "Member"
*
* The membership is consulted BEFORE the own-org fallback, because once a person
* can see more than one org the fallback is a guess. It used to end with a
* comment calling its own last branch unreachable — "a non-global-admin only
* ever sees their own org" — and that stopped being true the moment the picker
* started listing memberships: every joined org then rendered "Member",
* including ones the person administers.
*/
export function roleFor(org: Organization, ctx: PickerContext): string {
const isOwn = org.name === ctx.ownOrg
if (ctx.isSuperAdmin && !isOwn) return 'Super admin'
const membership = ctx.roles?.[org.name]
if (membership) return titleCase(membership)
if (isOwn) return ctx.callerIsAdmin ? 'Admin' : 'Member'
// A non-global-admin only ever sees their own org, so this is unreachable in
// practice; be honest rather than invent a role if it ever isn't.
// No membership row and not their own org: say the weaker thing rather than
// invent authority the caller may not have.
return ctx.isSuperAdmin ? 'Admin' : 'Member'
}
+29 -1
View File
@@ -20,7 +20,9 @@ import { useSession } from '~/lib/auth/session'
import { AccountApi, ApiError } from '~/lib/api'
import { MfaApi, type MfaSetup } from '~/lib/api/mfa'
import { ApiKeysView } from './ApiKeysModule'
import { FieldRow, PageHeader } from '@hanzo/ui/product'
import { FieldRow, FieldSwitch, PageHeader } from '@hanzo/ui/product'
import { usePreferences } from '~/lib/products/preferences'
import { NAV_CATALOG_PREF } from '~/lib/products/nav'
/** A labeled read-only value row; dim em-dash when empty. */
function InfoRow({ label, value }: { label: string; value?: string | number | boolean | null }) {
@@ -161,6 +163,30 @@ function PhotoCard() {
)
}
/**
* What the sidebar lists. Every product is available to every org, so the rail
* shows all of them by default; a person who works in six can put the catalog
* away and keep their pins. Nothing goes out of reach either way — the search box
* at the top of the rail and "All products" at its foot both stay whole.
*/
function SidebarCard() {
const prefs = usePreferences()
const catalog = prefs.get<boolean>(NAV_CATALOG_PREF, true)
return (
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" maxWidth={720}>
<FieldRow label="Show every product">
<XStack items="center" gap="$3">
<FieldSwitch checked={catalog} onChange={(v) => prefs.set(NAV_CATALOG_PREF, v)} />
<Text fontSize="$2" color="$color10">
List the whole catalog in the sidebar. Off, it shows what you pinned and
wherever you are; search and All products still reach everything.
</Text>
</XStack>
</FieldRow>
</Card>
)
}
function AccountTab() {
const { account, signOut } = useSession()
@@ -180,6 +206,8 @@ function AccountTab() {
</YStack>
</Card>
<SidebarCard />
<XStack>
<Button icon={<LogOut size={16} />} onPress={() => void signOut()}>Sign out</Button>
</XStack>
+120
View File
@@ -0,0 +1,120 @@
'use client'
/**
* Menu — the ONE anchored menu the console's two identity controls wear.
*
* The rail asks two questions at its two ends: WHERE you are (organization and
* project, top-left) and WHO you are (account, bottom-left). They are peers, and
* they must read as peers. They did not: the org control was a @hanzo/gui popover
* with a 20px rounded-square mark, a two-letter monogram and a chevron, while the
* account control was `@hanzo/iam`'s `UserMenu` — a second rendering system inside
* one rail, drawing raw DOM through an injected global `hz-iam-*` stylesheet into
* its own portal, with a 28px CIRCLE, a one-letter initial and no chevron. Two
* component systems, two marks, two type scales, two sets of a11y roles, for one
* shape. This is that shape once: the trigger, the sheet, and the rows beneath it.
*
* The MARK is `OrgMark` for both, because a person and an organization wear the
* same thing — an uploaded image when there is one, their own initials when there
* is not. One component means the two can no longer disagree about which.
*
* `children` receives `close`, so a row can dismiss the menu before it navigates
* without every call site keeping its own copy of the open state.
*/
import { useCallback, useState, type ReactNode } from 'react'
import { Button, Popover, Text, XStack } from '@hanzo/gui'
import { ChevronsUpDown } from '@hanzogui/lucide-icons-2'
import { OrgMark, type Org } from '@hanzo/ui/product'
import { Z } from '~/lib/z'
import { paper } from './paper'
/** The mark both controls wear. One number, so they cannot drift. */
export const MARK = 28
export function Menu({
mark,
label,
aria,
testId,
up,
onOpen,
children,
}: {
/** Who or what this names — an org, or a person as `{ name, logo: avatar }`. */
mark: Org
/** The one line in the trigger. Never empty: a nameless control reads as a bug. */
label: string
/** The accessible name (says what the control DOES, which the label alone cannot). */
aria: string
testId: string
/** Open UPWARD — for the control at the FOOT of the rail, where a downward sheet
* would fall off the bottom of the viewport. */
up?: boolean
/** Fired the first time the sheet opens — for a list that is fetched on demand. */
onOpen?: () => void
children: (close: () => void) => ReactNode
}) {
const [open, setOpen] = useState(false)
const close = useCallback(() => setOpen(false), [])
const change = useCallback(
(next: boolean) => {
setOpen(next)
if (next) onOpen?.()
},
[onOpen],
)
return (
// `up` is a PREFERENCE, not a promise: `allowFlip`/`stayInFrame` let the sheet
// turn over and slide back into view when the side it wants has no room. The
// account control is at the foot of the desktop rail (so: upward) and near the
// TOP of the phone's account sheet, where upward is 365px off the screen.
<Popover
open={open}
onOpenChange={change}
placement={up ? 'top-start' : 'bottom-start'}
allowFlip
stayInFrame
>
<Popover.Trigger asChild>
<Button
size="$3"
height={44}
chromeless
justify="flex-start"
px="$2"
data-testid={testId}
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
aria-label={aria}
>
<XStack items="center" gap="$2" flex={1} minW={0}>
<OrgMark org={mark} size={MARK} />
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{label}
</Text>
</XStack>
</Button>
</Popover.Trigger>
{/* `Z.popover` — the ladder's layer for a popover anchored inside a modal.
On a phone this menu opens from inside the account SHEET, which sits at
`Z.modal`; without a layer of its own it was measurable and unclickable. */}
<Popover.Content {...paper} role="menu" p="$2" width={280} style={{ zIndex: Z.popover }}>
{children(close)}
</Popover.Content>
</Popover>
)
}
/** A quiet heading over a group of rows — "Organization", "Project", "Theme". */
export function MenuLabel({ children }: { children: ReactNode }) {
return (
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
{children}
</Text>
)
}
/** The hairline between two groups of rows. */
export function MenuRule() {
return <XStack height={1} bg="$borderColor" my="$1" />
}
+3
View File
@@ -20,7 +20,10 @@
*/
export const paper = {
className: 'hz-paper hz-menu-in',
// `bordered` alone declares the intent and leaves the width at 0, so the sheet
// met the page with no edge at all. The hairline is stated.
bordered: true,
borderWidth: 1,
bg: '$color2',
borderColor: '$borderColor',
} as const
+202
View File
@@ -0,0 +1,202 @@
'use client'
/**
* The cloud shell — a REAL terminal in the Developers dock.
*
* What was here before was an explorer: a prompt that took `GET /v1/models` and
* printed the response. It looked like a shell and answered like a form, and the
* gap between the two is the whole reason this exists — there was no way to run
* anything, so the `$` in the dock was a promise the dock could not keep.
*
* THE SHELL IS A SANDBOX. Not a simulator, not a command allow-list: a login
* shell on a pseudo-terminal inside the org's own gVisor pod. Whatever the image
* carries — the hanzo CLI is on its PATH — is a command the user types, and
* nothing here decides what may run. That decision belongs to the runtime
* boundary the pod already has, and a second one in a browser tab would only be a
* fiction.
*
* THE TERMINAL IS NOT BUILT HERE. Cloud serves it, whole, at the same address as
* the socket, and this frames it. That is not laziness about an emulator — it is
* that the console is one of several hosts that show a shell, and a terminal
* built per host is a terminal that is subtly different in each of them. One
* implementation, one place a fix lands, and this file is left with the only part
* that is genuinely the console's: which sandbox, and what to say while it is
* coming up.
*
* WHAT THIS STILL OWNS is the credential. A frame carries no Authorization
* header any more than a socket does, so the ticket is fetched through the
* same-origin `/v1` proxy — where identity lives — and handed to the page in its
* URL. Single-use, thirty seconds, bound to one sandbox: that is what makes
* putting it in a URL safe, and why nothing long-lived ever goes there.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { ApiError, cloudProxyV1Url, restGet, restPost } from '~/lib/api/client'
import { config } from '~/config'
import { toneColor } from '~/components/ui/tone'
import { terminalFor } from './logic'
/**
* The project the dock's shell holds. A `dev` sandbox is attached to a project
* and the project names the VOLUME, so this constant is what makes the shell the
* same shell tomorrow: the checkout and the caches are still there when the lease
* has long since ended. One live sandbox per project is the server's rule, which
* is also why reopening the dock finds the running one instead of leasing a
* second.
*/
const PROJECT = 'console'
/** The tmux session this dock attaches to, so reopening it finds the same shell. */
const SESSION = 'dock'
/**
* How long to wait for the terminal to say it is up.
*
* The page posts `{source:'hanzo-term'}` when its socket opens. Without a
* deadline a frame that failed into something else — an expired ticket, an
* origin that refused to be framed — is indistinguishable from one that is still
* loading, and the dock would sit on "Starting…" forever rather than offering the
* reconnect that fixes it.
*/
const READY_BY = 6000
type Phase = 'starting' | 'live' | 'gone'
type Sandbox = { id: string; status: string; project?: string }
/**
* The org's running dock sandbox, or a freshly leased one.
*
* Asking the server which one is live is what makes the shell survive a reload
* without remembering anything: there is exactly one live sandbox per project by
* the server's own rule, so the answer to "which one is mine" is a query and
* never a stored id that can go stale. The match is re-checked here rather than
* trusted from the query string — a filter is the server's convenience, and the
* sandbox this reattaches to had better be the right one.
*/
async function sandbox(): Promise<Sandbox> {
const live = await restGet<{ sandboxes?: Sandbox[] }>(
cloudProxyV1Url(`sandboxes?project=${PROJECT}&status=running`),
)
const held = live.sandboxes?.find((m) => m.status === 'running' && m.project === PROJECT)
if (held) return held
return restPost<Sandbox>(cloudProxyV1Url('sandboxes'), { class: 'dev', project: PROJECT })
}
const reason = (err: unknown): string =>
err instanceof ApiError
? `${err.message}${err.status ? ` (${err.status})` : ''}`
: err instanceof Error
? err.message
: String(err)
export function Terminal() {
const [phase, setPhase] = useState<Phase>('starting')
const [why, setWhy] = useState('')
const [src, setSrc] = useState('')
// A change to this is the ONE way a session restarts: the effect below owns the
// whole lifetime — sandbox, ticket, frame — and reruns as a unit, so there is
// no half-torn-down session to reason about. A ticket is spent once, so a
// reconnect is a new ticket and never the old frame reloaded.
const [attempt, setAttempt] = useState(0)
const frame = useRef<HTMLIFrameElement>(null)
const retry = useCallback(() => {
setPhase('starting')
setWhy('')
setSrc('')
setAttempt((n) => n + 1)
}, [])
useEffect(() => {
// `alive` is the barrier for everything this effect started. React mounts an
// effect twice in development, and a ticket fetched by the first pass would
// otherwise land in a frame the second pass has replaced.
let alive = true
let waiting: ReturnType<typeof setTimeout> | null = null
const end = (message: string) => {
if (!alive) return
setWhy(message)
setPhase('gone')
}
// The readiness handshake. Only the frame we opened may speak for it: the
// origin is checked against the API host, so another page cannot post its way
// into a terminal that is not there.
const heard = (e: MessageEvent) => {
if (!alive || e.source !== frame.current?.contentWindow) return
if (new URL(config.apiUrl).origin !== e.origin) return
const d = e.data as { source?: string } | null
if (d && d.source === 'hanzo-term') {
if (waiting) clearTimeout(waiting)
setPhase('live')
}
}
window.addEventListener('message', heard)
void (async () => {
try {
const m = await sandbox()
if (!alive) return
const pass = await restPost<{ ticket: string }>(
cloudProxyV1Url(`sandboxes/${m.id}/terminal/ticket`),
)
if (!alive) return
setSrc(terminalFor(config.apiUrl, m.id, pass.ticket, SESSION))
waiting = setTimeout(() => end('The terminal did not come up.'), READY_BY)
} catch (err) {
end(reason(err))
}
})()
return () => {
alive = false
if (waiting) clearTimeout(waiting)
window.removeEventListener('message', heard)
}
}, [attempt])
// The frame is ALWAYS laid out and the status covers it, because a frame that
// is display:none has no size — and a terminal sized to nothing measures 80x24
// and never corrects.
return (
<YStack flex={1} minH={0} position="relative" bg="#000">
{src ? (
<iframe
ref={frame}
src={src}
title="Cloud shell"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
/>
) : null}
{phase === 'live' ? null : (
<YStack position="absolute" t={0} l={0} r={0} b={0} items="center" justify="center" gap="$2" p="$4" bg="$color1">
{phase === 'starting' ? (
<Text fontSize="$2" color="$color10">
Starting your cloud shell
</Text>
) : (
<>
<XStack items="center" gap="$2">
<Text fontSize="$2" color={toneColor('critical')}>
Disconnected
</Text>
<Button size="$2" onPress={retry} aria-label="Reconnect the cloud shell">
Reconnect
</Button>
</XStack>
{why ? (
<Text fontSize="$1" color="$color10" className="hz-mono">
{why}
</Text>
) : null}
</>
)}
</YStack>
)}
</YStack>
)
}
+1 -1
View File
@@ -289,7 +289,7 @@ export function WorkbenchDock() {
$
</Text>
<Text flex={1} fontSize="$2" color="$color10" className="hz-mono" numberOfLines={1}>
Run a /v1 command models, agents, logs
Open a cloud shell a real terminal in your sandbox
</Text>
</XStack>
) : (
+24 -31
View File
@@ -1,32 +1,6 @@
import { describe, expect, it } from 'vitest'
import { curlFor, eventsFrom, hanzoCli, inspectorRoute, parseCommand, renderOutput } from './logic'
describe('parseCommand', () => {
it('accepts every documented form and normalizes to a /v1-relative path', () => {
expect(parseCommand('GET /v1/models')).toEqual({ path: 'models' })
expect(parseCommand('get v1/models')).toEqual({ path: 'models' })
expect(parseCommand('/v1/models')).toEqual({ path: 'models' })
expect(parseCommand('models')).toEqual({ path: 'models' })
expect(parseCommand(' agents?limit=5 ')).toEqual({ path: 'agents?limit=5' })
})
it('is read-only — every mutating method is refused', () => {
for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) {
expect(parseCommand(`${m} /v1/agents`)).toHaveProperty('error')
}
})
it('refuses empty, multi-path, traversal, and smuggled inputs', () => {
expect(parseCommand('')).toHaveProperty('error')
expect(parseCommand('GET')).toHaveProperty('error')
expect(parseCommand('/v1')).toHaveProperty('error')
expect(parseCommand('GET /v1/a /v1/b')).toHaveProperty('error')
expect(parseCommand('/v1/../admin')).toHaveProperty('error')
expect(parseCommand('//evil.com/x')).toHaveProperty('error')
expect(parseCommand('https://evil.com/x')).toHaveProperty('error')
})
})
import { curlFor, eventsFrom, inspectorRoute, renderOutput, terminalFor } from './logic'
describe('renderOutput', () => {
it('pretty-prints JSON and passes strings through', () => {
@@ -77,10 +51,6 @@ describe('show code', () => {
expect(curlFor('models')).toBe('curl https://api.hanzo.ai/v1/models \\\n -H "Authorization: Bearer $HANZO_API_KEY"')
expect(curlFor('/v1/agents')).toContain('https://api.hanzo.ai/v1/agents')
})
it('builds the Hanzo CLI form', () => {
expect(hanzoCli('/v1/models')).toBe('hanzo api get /v1/models')
expect(hanzoCli('agents')).toBe('hanzo api get /v1/agents')
})
})
describe('eventsFrom', () => {
@@ -105,3 +75,26 @@ describe('eventsFrom', () => {
expect(eventsFrom([])).toEqual([])
})
})
describe('cloud shell — where the terminal is', () => {
it('addresses the API host, not the console origin — a frame cannot ride the /v1 proxy', () => {
expect(terminalFor('https://api.hanzo.ai', 'm_abc', 'tok', 'dock')).toBe(
'https://api.hanzo.ai/v1/sandboxes/m_abc/terminal?ticket=tok&arg=dock',
)
})
it('tolerates a trailing slash and escapes what it interpolates', () => {
expect(terminalFor('https://api.hanzo.ai/', 'm_1', 't', 'dock')).toContain(
'api.hanzo.ai/v1/sandboxes/m_1/terminal',
)
// An id is hex and a ticket is base64url, but neither is trusted to be: an
// unescaped `&` would silently truncate the credential.
expect(terminalFor('https://api.hanzo.ai', 'a/b', 'x&y=z', 'p 1')).toBe(
'https://api.hanzo.ai/v1/sandboxes/a%2Fb/terminal?ticket=x%26y%3Dz&arg=p%201',
)
})
it('names a session, so reopening the dock reattaches instead of starting over', () => {
expect(terminalFor('https://api.hanzo.ai', 'm_1', 't', 'dock')).toContain('arg=dock')
})
})
+31 -40
View File
@@ -1,41 +1,10 @@
/**
* Workbench logic — the PURE half of the bottom Developers dock (parse a shell
* command into a safe same-origin `/v1` read; render a response for the terminal).
* No React/Gui/registry imports so it is node-testable in isolation.
* Workbench logic — the PURE half of the bottom Developers dock: where the cloud
* shell's socket lives and what goes over it, how a response renders, and how an
* id routes to the `/v1` read that explains it. No React/Gui/registry imports, so
* every decision here is node-testable on its own.
*/
export type Command = { path: string } | { error: string }
const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])
/** Path charset — segments, query string; nothing that can smuggle a scheme/host. */
const PATH_OK = /^[A-Za-z0-9\-_./?=&%,:+]+$/
/**
* Parse a workbench shell line into a `/v1`-relative GET path. Accepted forms:
* `GET /v1/models` · `/v1/models` · `v1/models` · `models` (a bare head). The
* shell is deliberately READ-ONLY (GET) — a mutation belongs in the product UI,
* where it gets its confirm/undo affordances — and `/v1`-only (the one API root).
*/
export function parseCommand(input: string): Command {
const words = input.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return { error: 'Enter a path — e.g. GET /v1/models' }
let rest = words
const head = words[0].toUpperCase()
if (METHODS.has(head)) {
if (head !== 'GET') return { error: 'The workbench shell is read-only — only GET is supported.' }
rest = words.slice(1)
}
if (rest.length !== 1) return { error: 'One path per command — e.g. GET /v1/models' }
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(rest[0])) return { error: 'Only a /v1 path is allowed — e.g. /v1/models' }
let path = rest[0].replace(/^\/+/, '')
if (path === 'v1' || path === 'v1/') return { error: 'Name a resource — e.g. /v1/models' }
if (path.startsWith('v1/')) path = path.slice(3)
if (!path || !PATH_OK.test(path) || path.includes('..') || path.includes('//')) {
return { error: 'Only a /v1 path is allowed — e.g. /v1/models' }
}
return { path }
}
/** Pretty-print a shell response, bounded so a huge payload never wedges the DOM. */
export function renderOutput(value: unknown, maxChars = 20000): string {
let text: string
@@ -50,6 +19,9 @@ export function renderOutput(value: unknown, maxChars = 20000): string {
// ── Inspector — route an object id to its `/v1` GET ───────────────────────────
/** Path charset — segments and a query string; nothing that can smuggle a scheme/host. */
const PATH_OK = /^[A-Za-z0-9\-_./?=&%,:+]+$/
/** A resolved inspect target (a same-origin `/v1` GET) or an honest parse error. */
export type InspectTarget = { path: string; kind: string; label: string } | { error: string }
@@ -110,11 +82,6 @@ export function curlFor(path: string, origin = 'https://api.hanzo.ai'): string {
return `curl ${origin}/v1/${bareResource(path)} \\\n -H "Authorization: Bearer $HANZO_API_KEY"`
}
/** The Hanzo CLI form of the same `/v1` GET. */
export function hanzoCli(path: string): string {
return `hanzo api get /v1/${bareResource(path)}`
}
// ── Events — project the usage ledger into a platform-event stream ────────────
/** One platform event, projected from a real charged ledger row (never fabricated). */
@@ -152,3 +119,27 @@ export function eventsFrom(records: EventSource[]): PlatformEvent[] {
})
.sort((a, b) => (b.at ?? -Infinity) - (a.at ?? -Infinity))
}
// ── Cloud shell — where the terminal is ──────────────────────────────────────
/**
* The terminal's address on the API host.
*
* Cloud SERVES the terminal — emulator, socket, resize and reconnect, one
* self-contained page — so a host that wants a shell frames this rather than
* building one. It is the one address in the console that is not same-origin, and
* that is forced rather than chosen: the same-origin `/v1` proxy is a Next route
* handler, and a route handler forwards requests, not sockets and not frames.
* What crosses instead of the session is the single-use ticket the proxy just
* fetched, which is the only credential a URL can safely hold.
*
* `arg` names a tmux session, so reopening the dock reattaches to the shell it
* left instead of opening a fresh one over the user's work.
*/
export function terminalFor(apiBase: string, id: string, ticket: string, session: string): string {
const base = apiBase.trim().replace(/\/+$/, '')
return (
`${base}/v1/sandboxes/${encodeURIComponent(id)}/terminal` +
`?ticket=${encodeURIComponent(ticket)}&arg=${encodeURIComponent(session)}`
)
}
+9 -114
View File
@@ -51,7 +51,8 @@ import { config } from '~/config'
import { MetricCard } from '~/components/ui/Metric'
import { RuntimeNotice } from '~/components/products/observability/RuntimeNotice'
import { TracesModule } from '~/components/products/TracesModule'
import { curlFor, eventsFrom, hanzoCli, inspectorRoute, parseCommand, renderOutput, type PlatformEvent } from './logic'
import { curlFor, eventsFrom, inspectorRoute, renderOutput, type PlatformEvent } from './logic'
import { Terminal } from './Terminal'
import { toneColor } from '~/components/ui/tone'
const usd = (cents: number, dp = 2): string => `$${(cents / 100).toFixed(dp)}`
@@ -903,119 +904,13 @@ export function TracesTab() {
)
}
// ── Shell / API Explorer ─────────────────────────────────────────────────────────
type ShellEntry = { cmd: string; path: string; output: string; ok: boolean }
/** Quick GET resources — the common `/v1` reads, run with one tap. */
const SHELL_RESOURCES = ['models', 'agents', 'prompts', 'functions', 'automations/flows', 'billing/usage'] as const
// ── Shell — the cloud shell ──────────────────────────────────────────────────
/**
* The Shell tab IS a terminal. The component that carries it lives on its own
* (Terminal.tsx) because it loads xterm in the browser and owns a socket, and
* neither belongs in a file of render-only tabs.
*/
export function ShellTab() {
const [entries, setEntries] = useState<ShellEntry[]>([])
const [input, setInput] = useState('')
const [running, setRunning] = useState(false)
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ block: 'nearest' })
}, [entries])
const run = useCallback(async (line: string) => {
const cmd = line.trim()
if (!cmd || running) return
setInput('')
const parsed = parseCommand(cmd)
if ('error' in parsed) {
setEntries((e) => [...e, { cmd, path: '', output: parsed.error, ok: false }])
return
}
setRunning(true)
try {
const data = await restGet<unknown>(cloudProxyV1Url(parsed.path))
setEntries((e) => [...e, { cmd, path: parsed.path, output: renderOutput(data), ok: true }])
} catch (err) {
const output =
err instanceof ApiError ? `HTTP ${err.status}${err.message}` : err instanceof Error ? err.message : String(err)
setEntries((e) => [...e, { cmd, path: parsed.path, output, ok: false }])
} finally {
setRunning(false)
}
}, [running])
return (
<YStack flex={1} minH={0}>
{/* Resource picker — run a common /v1 GET with one tap. */}
<XStack items="center" gap="$1" px="$2" height={34} borderBottomWidth={1} borderColor="$borderColor" flexWrap="wrap">
<Text fontSize="$1" color="$color10">
GET
</Text>
{SHELL_RESOURCES.map((r) => (
<Button
key={r}
size="$1"
chromeless
bg="$color3"
rounded="$4"
px="$2"
onPress={() => void run(`GET /v1/${r}`)}
aria-label={`Run GET /v1/${r}`}
>
<Text fontSize="$1" color="$color11" className="hz-mono">
{r}
</Text>
</Button>
))}
</XStack>
<ScrollView flex={1} minH={0}>
<YStack p="$3" gap="$2">
{entries.length === 0 ? (
<Text fontSize="$1" color="$color10" className="hz-mono">
Read-only /v1 explorer pick a resource above or type `GET /v1/models`. Runs as you, in your org.
</Text>
) : null}
{entries.map((e, i) => (
<YStack key={i} gap="$1">
<XStack items="center" gap="$2">
<Text fontSize="$1" color="$color11" className="hz-mono" flex={1} numberOfLines={1}>
$ {e.cmd}
</Text>
{e.ok && e.path ? (
<>
<CopyBtn value={curlFor(e.path)} label="curl" />
<CopyBtn value={hanzoCli(e.path)} label="CLI" />
</>
) : null}
</XStack>
<Text fontSize="$1" color={e.ok ? '$color12' : toneColor('critical')} className="hz-mono" style={{ whiteSpace: 'pre-wrap' }}>
{e.output}
</Text>
</YStack>
))}
<div ref={endRef} />
</YStack>
</ScrollView>
<XStack items="center" gap="$2" px="$3" height={44} borderTopWidth={1} borderColor="$borderColor">
<Text fontSize="$2" color="$color10" className="hz-mono">
$
</Text>
<Input
flex={1}
unstyled
autoFocus
value={input}
onChangeText={setInput}
onSubmitEditing={() => void run(input)}
placeholder={running ? 'Running…' : 'Enter a /v1 command…'}
fontSize="$2"
color="$color12"
className="hz-mono"
autoCapitalize="none"
autoCorrect={false}
aria-label="Workbench shell command"
/>
</XStack>
</YStack>
)
return <Terminal />
}
+242 -274
View File
@@ -1,32 +1,35 @@
'use client'
/**
* Dashboard shell — a TWO-LEVEL sidebar (products, each expanding its own sub-pages
* in place) + top bar + content, responsive across phone / tablet / laptop / desktop.
* Dashboard shell — a TWO-LEVEL sidebar + top bar + content, responsive across
* phone / tablet / laptop / desktop.
*
* Level 1 (the product list) renders from the catalog: fixed Overview/Docs, a
* ONE LEVEL SHOWS AT A TIME, and the ROUTE picks which:
*
* Level 1 (anywhere outside a product) is the catalog: fixed Overview/Docs, a
* Pinned section the user curates, then every product grouped by category. Each
* CATEGORY is an INDEPENDENTLY collapsible section that renders EXPANDED by default
* (nothing auto-collapses); the header is flush-left with the top-level items and
* carries an OPTIONAL collapse chevron whose state persists per-user.
*
* Level 2 a product's sub-pages (Overview + specifics + the uniform base set:
* Settings · Status · Logs · Metrics) — expands BENEATH that product's own row, so
* its options appear without the rest of the catalog going away. The label
* navigates; the chevron beside it only expands or collapses, and that choice
* persists per-user (the product you are IN is open unless you closed it). Sub-pages
* with no backend yet are dimmed and open an honest placeholder, never a dead link.
* Level 2 (inside a product) is that product's pages Overview + specifics + the
* uniform base set (Settings · Status · Logs · Metrics) — sitting FLUSH where the
* catalog was, under the category you came through, with the rest of that category
* beneath them so a sibling stays one click away. A page with no backend yet is
* dimmed and opens an honest placeholder, never a dead link.
*
* This replaced a DRILL: clicking a product used to swap the whole rail for that
* product's sub-nav, behind a "Back to all products" button. The options were the
* same either way — what the drill took away was every OTHER product, which is
* precisely what someone needs when the reason they opened the rail was to go
* somewhere else.
* Both halves of that used to be true at once: the pages appeared INDENTED under the
* product's row while the whole catalog stayed painted below, so two levels shared
* the screen and a pinned product carried its pages in one place while its own
* category row sat inert in another — the same product twice, one of them dead. The
* level is a REPLACEMENT now, which is the only reading of "level" that stays true
* when the list is long.
*
* Level 2 is DECLARED once, in the registry (`subpages` + `indexLabel`), and read
* here and by `SubNav` (the same nav, for the viewports where this sidebar is a
* drawer). No module carries its own tab list. The level itself is carried by the
* URL and nothing else, so a reload, a deep link and Back all agree.
* URL and nothing else, so a reload, a deep link and Back all agree — there is no
* remembered expansion to disagree with them.
*
* The WHOLE sidebar collapses to an icon RAIL (the topbar panel toggle, persisted).
* When collapsed, HOVER reveals the full sidebar as an OVERLAY flyout (it doesn't
@@ -57,6 +60,7 @@ import {
BarChart3,
Bell,
BookOpen,
ChevronLeft,
ChevronRight,
Circle,
CircleHelp,
@@ -93,18 +97,15 @@ import { ConsoleFooter } from '~/components/ConsoleFooter'
import { openProduct } from '~/lib/products/open'
import { entryMatches } from '~/lib/products/search'
import { usePins, useProductColors } from '~/lib/products/pins'
import { useAppsBeta } from '~/lib/products/beta'
import { orderEntries } from '~/lib/products/order'
import {
categoryIsOpen,
toggleCategory,
productIsOpen,
toggleProduct,
NAV_OPEN_PREF,
NAV_PRODUCT_OPEN_PREF,
NAV_CATALOG_PREF,
EMPTY_OPEN,
type CategoryOpen,
} from '~/lib/products/nav-accordion'
} from '~/lib/products/nav'
import { usePreferences } from '~/lib/products/preferences'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { useEntitlements } from '~/lib/entitlements-context'
@@ -138,18 +139,6 @@ const CONTENT_MAX = 1680
/** Collapsed-rail icon size — large enough to be a comfortable hit target. */
const ICON = 20
/** Icons for the Billing Center tabs — used by the billing-only shell nav. */
const BILLING_SUBPAGE_ICON: Record<string, ComponentType<{ size?: number }>> = {
'': House,
reports: BarChart3,
budgets: Bell,
invoices: ScrollText,
subscriptions: Repeat,
'payment-methods': CreditCard,
credits: Wallet,
}
const billingSubpageIcon = (slug: string): ComponentType<{ size?: number }> => BILLING_SUBPAGE_ICON[slug] ?? Circle
/** The active in-console module id for a path, or null (home / external / unknown). */
function activeModuleId(pathname: string): string | null {
const seg = pathname.split('/').filter(Boolean)[0]
@@ -310,55 +299,49 @@ function NavRow({
}
/**
* Level 2 — a product's sub-pages, expanded IN PLACE beneath its own row. The list
* is `productSubpages(entry)` (Overview + specifics + the uniform base set), with an
* unwired sub-page dimmed but honest: it opens a placeholder, never a dead link.
* ONE level-2 row — a single page of the product the rail is currently showing.
*
* Indented under the product and hung on a hairline, so the nesting is legible
* without a second heading — the product's own row above IS the heading. Collapsed,
* the rows are `inert`, so hidden options leave the tab order.
* Level 2 is a REPLACEMENT, not a nesting: when a product is open the rail lists
* ITS pages, so these rows sit FLUSH with the level-1 rows they stand in for. They
* used to be indented under the product's row while the whole catalog stayed
* painted below, which put two levels on screen at once and left the reader to work
* out which list they were in.
*
* An unwired page is dimmed but honest — it opens a placeholder, never a dead link.
* Both faces render through this one row: the full console's level 2 and the
* product-shell face, whose nav IS its root module's pages.
*/
function SubRows({
entry,
subs,
pathname,
open,
function SubRow({
id,
sub,
active,
collapsed,
onGo,
}: {
entry: CatalogEntry
subs: ProductSubpage[]
pathname: string
open: boolean
id: string
sub: ProductSubpage
active: boolean
collapsed: boolean
onGo: (path: string) => void
}) {
const activeSlug = activeSubpage(pathname, entry.id)
const wired = subpageWired(id, sub.slug)
const Icon = sub.icon ?? subpageIcon(sub.slug)
return (
<div className="hz-acc" data-open={open ? 'true' : 'false'} id={`nav-sub-${entry.id}`} inert={!open}>
<div className="hz-acc-inner">
<YStack gap="$0.5" ml="$4" pl="$2" pt="$0.5" borderLeftWidth={1} borderColor="$borderColor">
{subs.map((sp) => {
const wired = subpageWired(entry.id, sp.slug)
const active = sp.slug === activeSlug
const SubIcon = sp.icon ?? subpageIcon(sp.slug)
return (
<Button
key={sp.slug || 'overview'}
onPress={() => onGo(sp.slug ? `/${entry.id}/${sp.slug}` : `/${entry.id}`)}
bg={active ? '$color4' : 'transparent'}
justify="flex-start"
icon={<SubIcon size={15} />}
iconAfter={!wired ? <Circle size={7} opacity={0.5} /> : undefined}
size="$2"
opacity={wired ? 1 : 0.6}
aria-label={wired ? sp.label : `${sp.label} (not available yet)`}
>
{sp.label}
</Button>
)
})}
</YStack>
</div>
</div>
<Button
onPress={() => onGo(sub.slug ? `/${id}/${sub.slug}` : `/${id}`)}
bg={active ? '$color4' : 'transparent'}
justify={collapsed ? 'center' : 'flex-start'}
px={collapsed ? '$0' : '$2.5'}
height={collapsed ? 44 : undefined}
icon={<Icon size={collapsed ? ICON : 17} />}
iconAfter={!collapsed && !wired ? <Circle size={7} opacity={0.5} /> : undefined}
size="$3"
opacity={wired ? 1 : 0.6}
aria-current={active ? 'page' : undefined}
aria-label={wired ? sub.label : `${sub.label} (not available yet)`}
>
{collapsed ? undefined : sub.label}
</Button>
)
}
@@ -473,11 +456,9 @@ function SidebarNav({
const { colorOf } = useProductColors()
const detail = useDetailPane()
const showAdmin = useIsSuperAdmin()
const showBeta = useAppsBeta(showAdmin)
// Entitlement scope: currently ungated in prod (the endpoint 404s → `enabled` is
// null → the full catalog shows), matching "every product is always available".
const { enabled } = useEntitlements()
const [filter, setFilter] = useState('')
// Collapsible-category accordion state — the user's EXPLICIT per-category collapse
// choices, persisted per-user. Everything is EXPANDED by default; a collapsed
@@ -485,75 +466,52 @@ function SidebarNav({
const prefs = usePreferences()
const navOpen = prefs.get<CategoryOpen>(NAV_OPEN_PREF, EMPTY_OPEN)
const toggleSection = (category: string) => prefs.set(NAV_OPEN_PREF, toggleCategory(navOpen, category))
// Does the rail list the whole catalog, or only what you keep? (Profile → Account.)
const catalogInRail = prefs.get<boolean>(NAV_CATALOG_PREF, true)
// ── Level 2, in place ─────────────────────────────────────────────────────
// A product's sub-pages expand beneath its own row; nothing replaces the list.
// ── Which level the rail is on ────────────────────────────────────────────
// The ROUTE decides, and nothing else: inside a product the rail shows THAT
// product's pages plus the rest of its category (level 2); anywhere else it shows
// the catalog (level 1). One level on screen at a time, and a reload, a deep link
// and Back all land on the same rail because none of it is remembered state.
const activeId = activeModuleId(pathname)
const isActive = (id: string) => pathname === `/${id}` || pathname.startsWith(`/${id}/`)
const productOpen = prefs.get<CategoryOpen>(NAV_PRODUCT_OPEN_PREF, EMPTY_OPEN)
const toggleExpand = (id: string) =>
prefs.set(NAV_PRODUCT_OPEN_PREF, toggleProduct(productOpen, id, { active: id === activeId }))
// Navigate to a LEAF (a sub-page or a no-sub-page product) — closes the drawer.
const go = (path: string) => {
router.push(path)
onNavigate()
}
// Open a product from the list. One with sub-pages keeps the drawer open, because
// becoming active expands it in place and its options are the next thing to read;
// a leaf navigates and closes. An external launch tile opens its deployed app in a
// new tab.
// Open a product from the list. One with pages keeps the drawer open, because the
// rail is about to become that product's pages and they are the next thing to
// read; a leaf navigates and closes. An external tile opens its app in a new tab.
const open = (entry: CatalogEntry) => {
if (entry.kind === 'external') {
openProduct(entry, go)
onNavigate()
return
}
setFilter('')
const subs = productSubpages(entry, showAdmin)
if (subs.length > 1) {
router.push(`/${entry.id}`) // its sub-pages open beneath it
if (productSubpages(entry, showAdmin).length > 1) {
router.push(`/${entry.id}`) // the rail becomes its pages
} else {
go(`/${entry.id}`) // leaf — navigate + close
}
}
/**
* ONE product row — the row itself plus, for a product that has them, its sub-pages
* expanded beneath. Both the Pinned group and the category groups render through
* this, so a product looks and behaves identically wherever it appears.
*/
const productRow = (entry: CatalogEntry, opts: { pinned?: boolean } = {}) => {
const subs = productSubpages(entry, showAdmin)
// A pinned product appears TWICE — once under Pinned, once in its category — and
// only ONE of those may carry the sub-pages. Two copies of the same list is two
// navs painting at once, which is the very thing this rail exists to avoid, and
// it doubles the rail's height for no information. The PINNED copy owns it: the
// user put it up there, and it is the one they read first.
const owns = opts.pinned || !isPinned(entry.id)
const expandable = owns && entry.kind === 'module' && subs.length > 1
const expanded = expandable && productIsOpen(productOpen, entry.id, { filtering, active: entry.id === activeId })
return (
<YStack key={`${opts.pinned ? 'pin' : 'cat'}-${entry.id}`} gap="$0.5">
<NavRow
entry={entry}
active={isActive(entry.id)}
color={colorOf(entry.id)}
collapsed={false}
pinned={opts.pinned ?? isPinned(entry.id)}
expandable={expandable}
expanded={expanded}
onExpand={expandable ? () => toggleExpand(entry.id) : undefined}
onOpen={() => open(entry)}
onToggle={opts.pinned ? undefined : () => toggle(entry.id)}
onCustomize={opts.pinned ? () => customize(entry) : undefined}
/>
{expandable ? (
<SubRows entry={entry} subs={subs} pathname={pathname} open={expanded} onGo={go} />
) : null}
</YStack>
)
}
/** ONE product row. Pinned rows carry the customize dot, catalog rows the pin star. */
const productRow = (entry: CatalogEntry, opts: { pinned?: boolean } = {}) => (
<NavRow
key={`${opts.pinned ? 'pin' : 'cat'}-${entry.id}`}
entry={entry}
active={isActive(entry.id)}
color={colorOf(entry.id)}
collapsed={false}
pinned={opts.pinned ?? isPinned(entry.id)}
onOpen={() => open(entry)}
onToggle={opts.pinned ? undefined : () => toggle(entry.id)}
onCustomize={opts.pinned ? () => customize(entry) : undefined}
/>
)
const openDocs = () => {
if (typeof window !== 'undefined') window.open(config.docsUrl, '_blank', 'noopener')
onNavigate()
@@ -580,9 +538,6 @@ function SidebarNav({
content: <AddProductPanel />,
})
const q = (collapsed ? '' : filter).trim().toLowerCase()
const filtering = q.length > 0
// Grouped pins, gated so a customer never sees an admin-only surface.
const pinnedGroups = useMemo(
() =>
@@ -591,28 +546,43 @@ function SidebarNav({
...g,
entries: g.entries.filter((e) => {
const found = findEntry(e.id)
return Boolean(found) && (showAdmin || !found!.admin) && (showBeta || !found!.beta)
return Boolean(found) && (showAdmin || !found!.admin)
}),
}))
.filter((g) => g.entries.length > 0),
[view, showAdmin, showBeta],
[view, showAdmin],
)
// Within-scope ordering is CONTINUOUS ALPHABETICAL with the SELECTED product pinned
// first — via the ONE shared `orderEntries` helper. Categories stay in their
// canonical order; only the items inside each are alphabetized + selected-first.
//
// "Show every product" opens the ENTITLEMENT scope, exactly as the All-products
// panel already does: every product is available to every org on demand, so what
// an org has ENABLED is a statement about use, not about permission. Off, the rail
// narrows to that enabled set. PERMISSION — admin surfaces, brand scope — holds
// either way, so nothing on the rail is ever a surface the viewer cannot open.
const groups = useMemo(
() =>
// Search is DISCOVERY: while a query is typed the entitlement scope opens
// to the whole catalog — the point of searching is finding what you do
// not have yet — while the admin and beta gates keep holding. The resting
// rail stays scoped to the org's enabled set.
visibleCatalogByCategory(showAdmin, filtering ? null : enabled, showBeta)
.map((g) => ({ category: g.category, entries: orderEntries(g.entries.filter((e) => entryMatches(e, q)), activeId) }))
visibleCatalogByCategory(showAdmin, catalogInRail ? null : enabled)
.map((g) => ({ category: g.category, entries: orderEntries(g.entries, activeId) }))
.filter((g) => g.entries.length > 0),
[q, filtering, showAdmin, showBeta, enabled, activeId],
[showAdmin, catalogInRail, enabled, activeId],
)
// ── Level 2 — the product the route is inside ─────────────────────────────
// Its pages become the rail, and the rest of its category follows them, so moving
// to a sibling stays one click.
const level = activeId ? findEntry(activeId) : undefined
// A product with no pages of its own is a LEAF: there is no second level to show,
// so the rail stays on the catalog with that row lit. Same rule `open` navigates by.
const here =
level && (showAdmin || !level.admin) && productSubpages(level, showAdmin).length > 1 ? level : undefined
const siblings =
here && catalogInRail
? (groups.find((g) => g.category === here.category)?.entries ?? []).filter((e) => e.id !== here.id)
: []
// ── Product-shell face — the nav IS the root module's sub-pages ────────────
if (isProductShell(config.shell)) {
const shell = shellFor(config.shell)
@@ -650,25 +620,16 @@ function SidebarNav({
) : null}
<ScrollView flex={1}>
<YStack gap="$1">
{subs.map((sp) => {
const active = sp.slug === activeSlug
const SubIcon = sp.icon ?? billingSubpageIcon(sp.slug)
return (
<Button
key={sp.slug || 'overview'}
onPress={() => go(sp.slug ? `/${rootId}/${sp.slug}` : `/${rootId}`)}
bg={active ? '$color4' : 'transparent'}
justify={collapsed ? 'center' : 'flex-start'}
px={collapsed ? '$0' : '$2.5'}
height={collapsed ? 44 : undefined}
icon={<SubIcon size={collapsed ? ICON : 17} />}
size="$3"
aria-label={sp.label}
>
{collapsed ? undefined : sp.label}
</Button>
)
})}
{subs.map((sp) => (
<SubRow
key={sp.slug || 'overview'}
id={rootId}
sub={sp}
active={sp.slug === activeSlug}
collapsed={collapsed}
onGo={go}
/>
))}
</YStack>
</ScrollView>
<SidebarAccount collapsed={collapsed} />
@@ -684,7 +645,7 @@ function SidebarNav({
const seen = new Set<string>()
for (const id of [...pinnedIds, ...(activeId ? [activeId] : [])]) {
const e = findEntry(id)
if (e && !seen.has(id) && (showAdmin || !e.admin) && (showBeta || !e.beta)) {
if (e && !seen.has(id) && (showAdmin || !e.admin)) {
seen.add(id)
railIds.push(id)
}
@@ -725,9 +686,8 @@ function SidebarNav({
)
}
// ── The product list: brand; filter; Overview/Docs; Pinned; every category
// (EXPANDED by default, collapsible), each product expanding its own sub-pages
// in place; All-products; identity + wallet. ──
// ── The rail: switcher; filter; Overview/Docs; then ONE level — the catalog, or
// the open product's pages followed by its category; All-products; identity. ──
return (
// The product rail is a NAVIGATION LANDMARK. It had no role at all, so a
// screen-reader user had no way to jump to the product list and no way to
@@ -744,109 +704,139 @@ function SidebarNav({
no switcher to carry the identity there. */}
<ContextSwitcher />
{/* Product filter — narrows the whole list; a match from any category jumps
straight there. Typing hides the section chrome so the list stays scannable. */}
<XStack
items="center"
gap="$2"
px="$2.5"
mb="$2"
height={34}
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
>
<Search size={14} opacity={0.6} />
<Input
flex={1}
unstyled
value={filter}
onChangeText={setFilter}
placeholder="Filter products…"
fontSize="$3"
color="$color12"
autoCapitalize="none"
autoCorrect={false}
/>
{filter ? (
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => setFilter('')} aria-label="Clear filter" />
) : null}
{/* Search — the SAME palette the header and the drawer open, so the whole
catalog (every product AND every page inside one) is one query away from
the rail, at either level. The rail used to carry its own text filter that
narrowed only the rows it had already drawn: a second search, answering a
smaller question. */}
<XStack mb="$2">
<CommandSearchBox />
</XStack>
<ScrollView flex={1} minH={0}>
<YStack gap="$3.5">
{!filtering ? (
<YStack gap="$1">
<FixedRow icon={House} label="Overview" active={pathname === '/'} collapsed={false} onPress={() => go('/')} />
<FixedRow icon={BookOpen} label="Docs" external collapsed={false} onPress={openDocs} />
</YStack>
) : null}
<YStack gap="$1">
<FixedRow icon={House} label="Overview" active={pathname === '/'} collapsed={false} onPress={() => go('/')} />
<FixedRow icon={BookOpen} label="Docs" external collapsed={false} onPress={openDocs} />
</YStack>
{!filtering && pinnedGroups.length > 0 ? (
<YStack gap="$1.5">
<XStack items="center" justify="space-between" px="$2.5">
<Text fontSize="$1" color="$color10" fontWeight="500">
Pinned
</Text>
<Button size="$1" chromeless onPress={manage} aria-label="Manage pins">
<Text fontSize="$1" color="$color10" fontWeight="700">
Manage
{here ? (
/* LEVEL 2 — the open product's pages, flush, then the rest of its
category. The catalog is not painted underneath: one level at a
time, and the category row above is the way back up to it. */
<>
<YStack gap="$1">
<Button
size="$2"
chromeless
height={32}
px="$2.5"
justify="flex-start"
onPress={() => go(`/category/${categorySlug(here.category)}`)}
icon={<ChevronLeft size={14} opacity={0.7} />}
aria-label={`Back to ${here.category}`}
>
<Text fontSize="$1" color="$color10" fontWeight="500">
{here.category}
</Text>
</Button>
</XStack>
{pinnedGroups.map((group) => (
<YStack key={group.name || 'default'} gap="$1">
{group.name ? (
<Text px="$2.5" fontSize="$1" color="$color9" fontWeight="700">
{group.label}
</Text>
) : null}
{group.entries.map((e) => {
const entry = findEntry(e.id)
if (!entry) return null
return productRow(entry, { pinned: true })
})}
{/* The product names the list below it — a heading, not a link:
its index is the first row, and two ways to the same page is
the duplication this level exists to remove. */}
<XStack items="center" gap="$2" px="$2.5" pb="$0.5">
<ProductIcon icon={here.icon} color={colorOf(here.id)} size={18} />
<Text fontSize="$3" fontWeight="700" color="$color12" numberOfLines={1}>
{here.label}
</Text>
</XStack>
{productSubpages(here, showAdmin).map((sp) => (
<SubRow
key={sp.slug || 'overview'}
id={here.id}
sub={sp}
active={sp.slug === activeSubpage(pathname, here.id)}
collapsed={false}
onGo={go}
/>
))}
</YStack>
{siblings.length > 0 ? (
<YStack gap="$1">
<Text px="$2.5" fontSize="$1" color="$color10" fontWeight="500">
More in {here.category}
</Text>
{siblings.map((entry) => productRow(entry))}
</YStack>
))}
</YStack>
) : null}
) : null}
</>
) : (
/* LEVEL 1 — what you pinned, then every category. */
<>
{pinnedGroups.length > 0 ? (
<YStack gap="$1.5">
<XStack items="center" justify="space-between" px="$2.5">
<Text fontSize="$1" color="$color10" fontWeight="500">
Pinned
</Text>
<Button size="$1" chromeless onPress={manage} aria-label="Manage pins">
<Text fontSize="$1" color="$color10" fontWeight="700">
Manage
</Text>
</Button>
</XStack>
{pinnedGroups.map((group) => (
<YStack key={group.name || 'default'} gap="$1">
{group.name ? (
<Text px="$2.5" fontSize="$1" color="$color9" fontWeight="700">
{group.label}
</Text>
) : null}
{group.entries.map((e) => {
const entry = findEntry(e.id)
if (!entry) return null
return productRow(entry, { pinned: true })
})}
</YStack>
))}
</YStack>
) : null}
{groups.map((group) => (
<CategorySection
key={group.category}
category={group.category}
count={group.entries.length}
open={categoryIsOpen(navOpen, group.category, { filtering })}
onToggle={() => toggleSection(group.category)}
>
{group.entries.map((entry) => productRow(entry))}
</CategorySection>
))}
{filtering && groups.length === 0 ? (
<Text px="$2.5" py="$3" fontSize="$2" color="$color10">
No products match {filter.trim()}.
</Text>
) : null}
{catalogInRail ? (
groups.map((group) => (
<CategorySection
key={group.category}
category={group.category}
count={group.entries.length}
open={categoryIsOpen(navOpen, group.category)}
onToggle={() => toggleSection(group.category)}
>
{group.entries.map((entry) => productRow(entry))}
</CategorySection>
))
) : level && !isPinned(level.id) ? (
/* The catalog is put away, so the rail is the pins — plus wherever
you are, which would otherwise be the one place with no row. */
<YStack gap="$1">{productRow(level)}</YStack>
) : null}
</>
)}
{/* Browse the full catalog — pin/unpin + find what's in use. Always
available (no enable gate; every product is always on). */}
{!filtering ? (
<Button
size="$2"
chromeless
justify="flex-start"
icon={<LayoutGrid size={16} />}
onPress={allProducts}
aria-label="All products"
mt="$1"
>
<Text fontSize="$2" color="$color11" fontWeight="600">
All products
</Text>
</Button>
) : null}
<Button
size="$2"
chromeless
justify="flex-start"
icon={<LayoutGrid size={16} />}
onPress={allProducts}
aria-label="All products"
mt="$1"
>
<Text fontSize="$2" color="$color11" fontWeight="600">
All products
</Text>
</Button>
</YStack>
</ScrollView>
@@ -868,29 +858,7 @@ function NavDrawer({ open, onOpenChange }: { open: boolean; onOpenChange: (o: bo
on phones/tablets (see globals.css); the desktop sidebar stays dense. */}
<YStack flex={1} minH={0} p="$3" gap="$2.5" className="hz-touch-target">
<XStack gap="$2" items="center">
<XStack
flex={1}
onPress={() => {
onOpenChange(false)
palette.open()
}}
cursor="pointer"
items="center"
gap="$2"
px="$3"
height={44}
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
hoverStyle={{ borderColor: '$color8' }}
>
<Search size={15} opacity={0.6} />
<Text flex={1} fontSize="$3" color="$color10" numberOfLines={1}>
Search or ask AI
</Text>
<Command size={13} opacity={0.5} />
</XStack>
<CommandSearchBox height={44} onOpen={() => onOpenChange(false)} />
{/* No second "Apps" trigger here either — the search row above opens the
very same palette, so one row is the whole affordance. */}
{/* Explicit close — right-aligned INSIDE the drawer header, always reachable
+11
View File
@@ -27,6 +27,17 @@ export function titleCase(name: string): string {
return name ? name.charAt(0).toUpperCase() + name.slice(1) : name
}
/**
* What to CALL an org — its IAM display name when it has one, else its slug
* titled. ONE rule, so the switcher's trigger, the row for that same org inside
* it, and the rail's brand all say the same word. They used to disagree: the
* trigger titled the slug and the rows printed it raw, so the control could read
* "Acme" over an active row reading "acme".
*/
export function orgLabel(org: { name?: string; displayName?: string }): string {
return org.displayName?.trim() || titleCase(org.name ?? '')
}
/**
* The one-line summary the context switcher shows: the org, then the project
* slice of it. Org-level scope has no project, and says so by omission rather
+1 -1
View File
@@ -110,7 +110,7 @@ export {
type RawValidator,
type RawPeer,
} from './nodes'
export { TeamApi } from './team'
export { TeamApi, MembershipApi, orgNamesFor, type Membership } from './team'
export {
PlaygroundApi,
type ChatMessage,
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { orgNamesFor } from './team'
describe('orgNamesFor — the orgs a person may act in', () => {
it('leads with the home org, then the memberships', () => {
expect(
orgNamesFor('hanzo', [
{ user: 'hanzo/dave', org: 'maxpower', role: 'admin' },
{ user: 'hanzo/dave', org: 'acme', role: 'member' },
]),
).toEqual(['hanzo', 'maxpower', 'acme'])
})
it('never repeats the home org when it is also a membership row', () => {
expect(
orgNamesFor('hanzo', [
{ user: 'hanzo/dave', org: 'hanzo', role: 'member' },
{ user: 'hanzo/dave', org: 'maxpower', role: 'admin' },
]),
).toEqual(['hanzo', 'maxpower'])
})
it('is the home org alone when there are no memberships', () => {
expect(orgNamesFor('hanzo', [])).toEqual(['hanzo'])
})
it('drops blank names rather than rendering a nameless card', () => {
expect(
orgNamesFor('hanzo', [
{ user: 'hanzo/dave', org: '', role: 'member' },
{ user: 'hanzo/dave', org: ' ', role: 'member' },
{ user: 'hanzo/dave', org: 'maxpower', role: 'admin' },
]),
).toEqual(['hanzo', 'maxpower'])
})
// A signed-in person with no resolvable home org still gets an honest empty
// list rather than a card named "".
it('is empty when there is no home org and no membership', () => {
expect(orgNamesFor('', [])).toEqual([])
})
})
+40
View File
@@ -21,6 +21,46 @@ import type { Organization, IamUser, Role } from './admin'
* email/OTP is not wired on this deployment). */
export type InviteLink = { link: string; org: string; name: string; email: string }
/** One org a person may act in, with the role they hold there. */
export type Membership = { user: string; org: string; role: string }
/**
* The organizations THIS person may act in.
*
* A person's account lives in ONE tenant; the organizations they work in are a
* SET, and the membership rows are that set — the same one the token's `orgs`
* claim carries and the same one IAM's policy authorizes reads with. Anything
* that lists "your orgs" reads this: deriving the list from the account's owner
* instead is how a second org became invisible and a card ended up titled with
* the signed-in person's name.
*
* The caller's HOME org is not necessarily a row here (it is implicit), so
* callers union it in — {@link orgNamesFor} does.
*/
export const MembershipApi = {
mine: async (userId: string): Promise<Membership[]> => {
const { rows } = await iamList<Membership>('memberships', { user: userId })
return rows.filter((m) => m && typeof m.org === 'string' && m.org !== '')
},
}
/**
* Every org name the caller can act in, home org FIRST and duplicates removed.
* Pure, so the ordering rule is testable without a network: the home org leads
* because it is the one a person lands in by default.
*/
export function orgNamesFor(homeOrg: string, memberships: Membership[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const name of [homeOrg, ...memberships.map((m) => m.org)]) {
const n = (name ?? '').trim()
if (!n || seen.has(n)) continue
seen.add(n)
out.push(n)
}
return out
}
export const TeamApi = {
/** Members of `orgName` (the caller's own org, or any for a global admin). */
members: (orgName: string, params: ListParams = {}): Promise<Paged<IamUser>> =>
-57
View File
@@ -54,46 +54,6 @@ export const ALWAYS_ON_PRODUCTS: readonly string[] = [
'captable', // the org's capitalization ledger — foundational company surface, every org (peer of 'company')
]
/**
* THE LAUNCH SET — what a new signup sees on console.hanzo.ai tonight.
*
* We are launching with hanzo.chat, hanzo.app and the console, so the console
* shows exactly what those need and nothing else. Every other product in the
* catalog (the whole cloud: compute, data, network, security, web3, the app
* suite, the fleet admin) is BETA — present, routable, and invisible until an
* org holds the beta flag. A superadmin always sees everything.
*
* This is an ALLOW-LIST on purpose: a new product added to the catalog is
* hidden by DEFAULT and joins the launch only when someone names it here. The
* inverse (a deny-list) leaks every future addition onto a customer's first
* screen.
*
* The set: the AI plane the two products run on, the credential to call it,
* the money surfaces, org/account management, and the beta door itself.
*/
export const LAUNCH_PRODUCTS: readonly string[] = [
// the console itself
'overview', // the home board
'beta-features', // the door to everything else — never behind its own flag
// the AI plane hanzo.chat + hanzo.app run on
'chat', // hanzo.chat
'models', // the model catalog
'playground', // try a model
'api-keys', // the credential both products call with
'usage', // what the AI cost
'logs', // the request log for those calls
// money
'billing',
'plans',
// org + account
'settings',
'team',
'profile',
]
/** True when a product is part of tonight's launch surface. */
export const isLaunchProduct = (id: string): boolean => LAUNCH_PRODUCTS.includes(id)
/** True when a product is always-on (implicit, never stored in `enabled`). */
export const isAlwaysOn = (id: string): boolean => ALWAYS_ON_PRODUCTS.includes(id)
@@ -130,23 +90,6 @@ export function filterEntitled<T extends { id: string }>(
* semantics so the UI can optimistically preview a change with the same result the
* backend would compute. Remove wins over add for the same id in one patch.
*/
/**
* Keep only the entries a viewer's BETA standing admits. Pure and generic like
* `filterEntitled`, and the same one-predicate rule: a superadmin sees
* everything, a beta org sees everything, everyone else loses `beta: true`
* entries. Fails CLOSED — callers that have not asked the enablement plane
* pass `showBeta: false` and beta surfaces stay hidden.
*/
export function filterBeta<T extends { id: string; beta?: boolean }>(
entries: readonly T[],
showBeta: boolean,
showAdmin: boolean,
): T[] {
if (showAdmin || showBeta) return [...entries]
// Beta is the COMPLEMENT of the launch set: outside it, or stamped.
return entries.filter((e) => isLaunchProduct(e.id) && e.beta !== true)
}
export function nextEnabled(current: readonly string[], patch: EntitlementPatch): string[] {
const set = new Set<string>(current)
for (const id of patch.add ?? []) if (id) set.add(id)
-46
View File
@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest'
import { filterBeta, LAUNCH_PRODUCTS } from '~/lib/entitlements'
// We launch with hanzo.chat, hanzo.app and the console: the launch set is an
// ALLOW-LIST, so everything else is beta by default and a NEW catalog entry is
// hidden the day it lands. That default is the whole point — pin it.
describe('filterBeta — the launch gate', () => {
const entries = [
{ id: 'chat' },
{ id: 'models' },
{ id: 'api-keys' },
{ id: 'beta-features' },
{ id: 'crm' },
{ id: 'gpus' },
{ id: 'lux-bridge' },
{ id: 'a-product-nobody-has-written-yet' },
]
it('shows the launch set and hides everything else', () => {
expect(filterBeta(entries, false, false).map((e) => e.id)).toEqual([
'chat',
'models',
'api-keys',
'beta-features',
])
})
it('a brand-new catalog entry is hidden by DEFAULT, not by remembering to stamp it', () => {
const shown = filterBeta([{ id: 'something-new-2027' }], false, false)
expect(shown).toEqual([])
})
it('the flag reveals everything; a superadmin never needed it', () => {
expect(filterBeta(entries, true, false)).toHaveLength(entries.length)
expect(filterBeta(entries, false, true)).toHaveLength(entries.length)
})
it('the beta door itself is in the launch set — otherwise nobody can opt in', () => {
expect(LAUNCH_PRODUCTS).toContain('beta-features')
})
it('a stamped entry inside the launch set can still ship dark', () => {
expect(filterBeta([{ id: 'chat', beta: true }], false, false)).toEqual([])
})
})
-67
View File
@@ -1,67 +0,0 @@
'use client'
/**
* useAppsBeta — does this viewer see the beta (Apps) surfaces?
*
* ONE source of truth: the enablement plane (`/v1/enablement`), the same
* self-service opt-in the Beta features module manages, scoped server-side to
* the caller's validated org. The gate looks for the `apps` feature (kind
* `feature`, id `apps`) being EFFECTIVE for the org — an admin sets it to
* `beta` (optionally granting orgs), users opt in where allowed, and this hook
* simply reads the resulting truth.
*
* Fails CLOSED: until the read answers — and whenever it refuses — beta
* surfaces stay hidden. A superadmin always sees them (mirror of the `admin`
* gate, and the only way the flag surface itself can be administered when the
* plane is down). Cached for the session like the org identity is: every nav
* surface asks, one request answers.
*/
import { useEffect, useState } from 'react'
import { EnablementApi } from '~/lib/api/admin-cockpit'
const APPS_KIND = 'feature'
const APPS_ID = 'apps'
let cached: boolean | null = null
let inflight: Promise<boolean> | null = null
async function readAppsBeta(): Promise<boolean> {
if (cached !== null) return cached
if (!inflight) {
inflight = EnablementApi.view()
.then((v) => {
const all = [...v.items, ...v.betas]
const hit = all.find((i) => i.kind === APPS_KIND && i.id === APPS_ID)
cached = Boolean(hit?.effective)
return cached
})
.catch(() => {
// A refusal is not an entitlement. Do not cache it — the next mount
// may be after sign-in or after the plane recovers.
inflight = null
return false
})
}
return inflight
}
export function useAppsBeta(isSuperAdmin: boolean): boolean {
const [on, setOn] = useState<boolean>(() => isSuperAdmin || cached === true)
useEffect(() => {
if (isSuperAdmin) {
setOn(true)
return
}
let live = true
readAppsBeta().then((v) => {
if (live) setOn(v)
})
return () => {
live = false
}
}, [isSuperAdmin])
return on
}
-116
View File
@@ -1,116 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
categoryIsOpen,
toggleCategory,
productIsOpen,
toggleProduct,
NAV_OPEN_PREF,
NAV_PRODUCT_OPEN_PREF,
type CategoryOpen,
} from './nav-accordion'
const ctx = (filtering = false) => ({ filtering })
describe('categoryIsOpen (expand-by-default)', () => {
it('defaults to EXPANDED for an untouched category (nothing auto-collapses)', () => {
expect(categoryIsOpen({}, 'AI', ctx())).toBe(true)
expect(categoryIsOpen({}, 'Observe', ctx())).toBe(true)
expect(categoryIsOpen({ Data: false }, 'AI', ctx())).toBe(true) // untouched section stays open
})
it('respects an explicit COLLAPSE, and stays where the user left it', () => {
expect(categoryIsOpen({ Observe: false }, 'Observe', ctx())).toBe(false)
// a re-opened section (explicit true) stays open
expect(categoryIsOpen({ Observe: true }, 'Observe', ctx())).toBe(true)
})
it('each section is INDEPENDENT — collapsing one leaves the others expanded', () => {
const stored: CategoryOpen = { Observe: false }
expect(categoryIsOpen(stored, 'Observe', ctx())).toBe(false)
expect(categoryIsOpen(stored, 'AI', ctx())).toBe(true)
expect(categoryIsOpen(stored, 'Platform', ctx())).toBe(true)
})
it('opens every group while filtering, so a search match is never hidden', () => {
expect(categoryIsOpen({ AI: false }, 'AI', ctx(true))).toBe(true)
expect(categoryIsOpen({ Observe: false }, 'Observe', ctx(true))).toBe(true)
})
it('restores the stored/default state once filtering clears', () => {
const stored: CategoryOpen = { AI: false, Data: true }
expect(categoryIsOpen(stored, 'AI', ctx(false))).toBe(false)
expect(categoryIsOpen(stored, 'Data', ctx(false))).toBe(true)
expect(categoryIsOpen(stored, 'Compute', ctx(false))).toBe(true) // untouched → open
})
})
describe('toggleCategory (independent per-section)', () => {
it('collapses a default-open (untouched) category on first toggle', () => {
expect(toggleCategory({}, 'AI')).toEqual({ AI: false })
})
it('re-opens an explicitly-collapsed category', () => {
expect(toggleCategory({ AI: false }, 'AI')).toEqual({ AI: true })
})
it('round-trips: toggling twice returns to the default-open state (stored true)', () => {
const once = toggleCategory({}, 'Observe')
expect(once).toEqual({ Observe: false })
const twice = toggleCategory(once, 'Observe')
expect(twice).toEqual({ Observe: true })
expect(categoryIsOpen(twice, 'Observe', ctx())).toBe(true)
})
it('NEVER touches other sections (independent — not single-open)', () => {
// collapsing AI leaves an already-collapsed Observe collapsed and everything else default-open
expect(toggleCategory({ Observe: false }, 'AI')).toEqual({ Observe: false, AI: false })
// opening a second section does NOT collapse the first (the old single-open invariant is gone)
expect(toggleCategory({ Observe: false, Data: false }, 'AI')).toEqual({ Observe: false, Data: false, AI: false })
})
it('is immutable — never mutates the input state', () => {
const stored: CategoryOpen = { AI: false }
const next = toggleCategory(stored, 'AI')
expect(stored).toEqual({ AI: false })
expect(next).not.toBe(stored)
})
})
describe('productIsOpen / toggleProduct', () => {
// Where you are is the one product whose options you are certain to want.
it('opens the ACTIVE product and leaves the others closed', () => {
expect(productIsOpen({}, 'agents', { filtering: false, active: true })).toBe(true)
expect(productIsOpen({}, 'models', { filtering: false, active: false })).toBe(false)
})
it('respects an explicit choice over the active default, in both directions', () => {
expect(productIsOpen({ agents: false }, 'agents', { filtering: false, active: true })).toBe(false)
expect(productIsOpen({ models: true }, 'models', { filtering: false, active: false })).toBe(true)
})
// The filter narrows PRODUCTS; a matched product's sub-pages are not themselves
// matches, so expanding them would push the other hits off screen.
it('closes everything while filtering, active or not', () => {
expect(productIsOpen({}, 'agents', { filtering: true, active: true })).toBe(false)
expect(productIsOpen({ agents: true }, 'agents', { filtering: true, active: true })).toBe(false)
})
// Whichever way the chevron points, the click does that.
it('first click on the active one collapses it; on any other one expands it', () => {
expect(toggleProduct({}, 'agents', { active: true })).toEqual({ agents: false })
expect(toggleProduct({}, 'models', { active: false })).toEqual({ models: true })
})
it('leaves every other product untouched and never mutates the input', () => {
const stored = { models: true }
expect(toggleProduct(stored, 'agents', { active: false })).toEqual({ models: true, agents: true })
expect(stored).toEqual({ models: true })
})
// Products and categories share a preference SHAPE but not a default, and they are
// stored under different keys — a product must never inherit a category's open-by-default.
it('is keyed apart from the category accordion', () => {
expect(NAV_PRODUCT_OPEN_PREF).not.toBe(NAV_OPEN_PREF)
})
})
-105
View File
@@ -1,105 +0,0 @@
/**
* Sidebar category accordion — the pure open/collapse model for the level-1
* product nav. Each CATEGORY is an INDEPENDENTLY collapsible section; this module
* holds the tiny decision logic (what renders open, how a toggle mutates it) with
* NO React, so it is unit-testable in isolation and the shell (`Dashboard`)
* stays a thin binding over it.
*
* EXPAND-BY-DEFAULT: every category renders EXPANDED by default — nothing
* auto-collapses, so the whole product catalog reads at a glance (OBSERVE, PLATFORM,
* DEV, APPS, SETTINGS, … all open). A user may explicitly COLLAPSE any section (the
* optional per-section chevron); that ONE choice is persisted per-user (account-backed
* + localStorage cache) via `usePreferences` under `NAV_OPEN_PREF` and RESPECTED on
* every render — the section stays exactly where the user left it. While FILTERING,
* every section opens so a search match is never hidden behind a collapsed section.
*
* This is NOT a single-open accordion: collapsing one section leaves the others
* untouched (each is independent), so the default is a fully-expanded nav.
*/
/** The user's EXPLICIT per-section open/closed choices (sparse). A MISSING key = the
* default (OPEN). A stored `false` = the user collapsed that section; `true` = the
* user re-opened one they'd collapsed. Kept as a Record for preference-shape stability. */
export type CategoryOpen = Partial<Record<string, boolean>>
/** Preference key (account-backed prefs) for the accordion open-state. */
export const NAV_OPEN_PREF = 'navCategoriesOpen'
/** A stable empty reference for the prefs fallback (avoids a fresh object per read,
* which would otherwise re-trigger memo/effect deps downstream). */
export const EMPTY_OPEN: CategoryOpen = {}
/**
* Whether a category renders EXPANDED, given the user's stored choices + context:
* - while FILTERING: always open, so a match is never hidden behind a collapsed
* section (the group list is already narrowed to non-empty matches);
* - otherwise: the user's EXPLICIT choice if they made one, else the DEFAULT (OPEN).
* A section the user never touched is open; one they collapsed stays collapsed
* (and one they re-opened stays open) — it stays where the user left it.
*/
export function categoryIsOpen(
stored: CategoryOpen,
category: string,
ctx: { filtering: boolean },
): boolean {
if (ctx.filtering) return true
const v = stored[category]
return v === undefined ? true : v
}
/**
* Toggle a single category (pure + immutable — never mutates the input). Each
* section is INDEPENDENT (NOT single-open): toggling one leaves every other one
* exactly as it was. A section with no stored choice is OPEN by default, so its
* first toggle COLLAPSES it (stores `false`); toggling again re-opens it (`true`).
*/
export function toggleCategory(stored: CategoryOpen, category: string): CategoryOpen {
const current = stored[category] === undefined ? true : stored[category]
return { ...stored, [category]: !current }
}
// ── Products, which expand IN PLACE ─────────────────────────────────────────
//
// A product with sub-pages expands beneath its own row, so its options appear
// without the rest of the catalog disappearing. This replaced a DRILL — clicking a
// product used to swap the entire rail for that product's sub-nav, behind a "Back to
// all products" button. The options were the same either way; what the drill took
// away was every other product, which is exactly what a person needs to see when the
// reason they clicked was to compare or to move on somewhere else.
//
// The default here is the OPPOSITE of a category's, and deliberately: categories are
// few and describe the whole catalog, so they open; products are many and each brings
// four to eight rows, so opening them all would bury the catalog under its own detail.
/** Preference key for which products are expanded in the rail. */
export const NAV_PRODUCT_OPEN_PREF = 'navProductsOpen'
/**
* Whether a product's sub-pages render EXPANDED:
* - while FILTERING: closed. The filter narrows PRODUCTS, and a matched product's
* sub-pages are not themselves matches — expanding them would push the other hits
* off screen;
* - the ACTIVE product: open, unless the user explicitly collapsed it. Where you are
* is the one place whose options you are certain to want;
* - otherwise: the user's explicit choice, else CLOSED.
*/
export function productIsOpen(
stored: CategoryOpen,
id: string,
ctx: { filtering: boolean; active: boolean },
): boolean {
if (ctx.filtering) return false
const v = stored[id]
return v === undefined ? ctx.active : v
}
/**
* Toggle one product's expansion (pure + immutable). The stored value is what the
* product is being toggled AWAY from, so the first click on the active product
* collapses it and the first click on any other one expands it — in both cases the
* click does the thing the chevron was pointing at.
*/
export function toggleProduct(stored: CategoryOpen, id: string, ctx: { active: boolean }): CategoryOpen {
const current = stored[id] === undefined ? ctx.active : stored[id]
return { ...stored, [id]: !current }
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { categoryIsOpen, toggleCategory, type CategoryOpen } from './nav'
describe('categoryIsOpen (expand-by-default)', () => {
it('defaults to EXPANDED for an untouched category (nothing auto-collapses)', () => {
expect(categoryIsOpen({}, 'AI')).toBe(true)
expect(categoryIsOpen({}, 'Observe')).toBe(true)
expect(categoryIsOpen({ Data: false }, 'AI')).toBe(true) // untouched section stays open
})
it('respects an explicit COLLAPSE, and stays where the user left it', () => {
expect(categoryIsOpen({ Observe: false }, 'Observe')).toBe(false)
// a re-opened section (explicit true) stays open
expect(categoryIsOpen({ Observe: true }, 'Observe')).toBe(true)
})
it('each section is INDEPENDENT — collapsing one leaves the others expanded', () => {
const stored: CategoryOpen = { Observe: false }
expect(categoryIsOpen(stored, 'Observe')).toBe(false)
expect(categoryIsOpen(stored, 'AI')).toBe(true)
expect(categoryIsOpen(stored, 'Platform')).toBe(true)
})
})
describe('toggleCategory (independent per-section)', () => {
it('collapses a default-open (untouched) category on first toggle', () => {
expect(toggleCategory({}, 'AI')).toEqual({ AI: false })
})
it('re-opens an explicitly-collapsed category', () => {
expect(toggleCategory({ AI: false }, 'AI')).toEqual({ AI: true })
})
it('round-trips: toggling twice returns to the default-open state (stored true)', () => {
const once = toggleCategory({}, 'Observe')
expect(once).toEqual({ Observe: false })
const twice = toggleCategory(once, 'Observe')
expect(twice).toEqual({ Observe: true })
expect(categoryIsOpen(twice, 'Observe')).toBe(true)
})
it('NEVER touches other sections (independent — not single-open)', () => {
// collapsing AI leaves an already-collapsed Observe collapsed and everything else default-open
expect(toggleCategory({ Observe: false }, 'AI')).toEqual({ Observe: false, AI: false })
// opening a second section does NOT collapse the first (the old single-open invariant is gone)
expect(toggleCategory({ Observe: false, Data: false }, 'AI')).toEqual({ Observe: false, Data: false, AI: false })
})
it('is immutable — never mutates the input state', () => {
const stored: CategoryOpen = { AI: false }
const next = toggleCategory(stored, 'AI')
expect(stored).toEqual({ AI: false })
expect(next).not.toBe(stored)
})
})
+68
View File
@@ -0,0 +1,68 @@
/**
* The sidebar's persisted view model — what the rail SHOWS and what is OPEN, as
* pure values with NO React, so the decisions are unit-testable in isolation and
* the shell (`Dashboard`) stays a thin binding over them.
*
* EXPAND-BY-DEFAULT: every category renders EXPANDED by default — nothing
* auto-collapses, so the whole product catalog reads at a glance (OBSERVE, PLATFORM,
* DEV, APPS, SETTINGS, … all open). A user may explicitly COLLAPSE any section (the
* optional per-section chevron); that ONE choice is persisted per-user (account-backed
* + localStorage cache) via `usePreferences` under `NAV_OPEN_PREF` and RESPECTED on
* every render — the section stays exactly where the user left it.
*
* This is NOT a single-open accordion: collapsing one section leaves the others
* untouched (each is independent), so the default is a fully-expanded nav.
*/
/** The user's EXPLICIT per-section open/closed choices (sparse). A MISSING key = the
* default (OPEN). A stored `false` = the user collapsed that section; `true` = the
* user re-opened one they'd collapsed. Kept as a Record for preference-shape stability. */
export type CategoryOpen = Partial<Record<string, boolean>>
/** Preference key (account-backed prefs) for the accordion open-state. */
export const NAV_OPEN_PREF = 'navCategoriesOpen'
/** A stable empty reference for the prefs fallback (avoids a fresh object per read,
* which would otherwise re-trigger memo/effect deps downstream). */
export const EMPTY_OPEN: CategoryOpen = {}
/**
* Whether a category renders EXPANDED: the user's EXPLICIT choice if they made one,
* else the DEFAULT (OPEN). A section the user never touched is open; one they
* collapsed stays collapsed (and one they re-opened stays open) — it stays where
* the user left it.
*/
export function categoryIsOpen(stored: CategoryOpen, category: string): boolean {
const v = stored[category]
return v === undefined ? true : v
}
/**
* Toggle a single category (pure + immutable — never mutates the input). Each
* section is INDEPENDENT (NOT single-open): toggling one leaves every other one
* exactly as it was. A section with no stored choice is OPEN by default, so its
* first toggle COLLAPSES it (stores `false`); toggling again re-opens it (`true`).
*/
export function toggleCategory(stored: CategoryOpen, category: string): CategoryOpen {
const current = stored[category] === undefined ? true : stored[category]
return { ...stored, [category]: !current }
}
// ── Whether the rail lists the catalog ───────────────────────────────────────
/**
* Preference key: does the rail list EVERY product, or only the ones you keep?
*
* ON (the default) the rail is the whole catalog — every product the viewer may
* SEE. Permission decides that (admin surfaces stay hidden; a brand shows only its
* own categories); what the org has ENABLED does not, because every product is
* available to every org on demand. The All-products panel had already settled this
* for itself and the rail had not, so one catalog came back two different sizes
* depending which you asked.
*
* OFF narrows the rail to the org's enabled set, plus what you pinned and wherever
* you are — just what you work with. Nothing is unreachable either way: the search
* box at the head of the rail asks the whole catalog, and "All products" at its foot
* lists all of it.
*/
export const NAV_CATALOG_PREF = 'navCatalog'
+4 -30
View File
@@ -109,7 +109,7 @@ import { Users,
} from '@hanzogui/lucide-icons-2'
import { config, type BrandId, type ShellId } from '~/config'
import { ALWAYS_ON_PRODUCTS, filterBeta, filterEntitled, isLaunchProduct } from '~/lib/entitlements'
import { ALWAYS_ON_PRODUCTS, filterEntitled } from '~/lib/entitlements'
import { type ProductCategory, categoryOrder, categoriesForBrand, categoryInBrand } from './brand-scope'
import { shellFor, isProductShell } from './shell'
import { ProvidersModule } from '~/components/products/ProvidersModule'
@@ -421,14 +421,6 @@ type CatalogBase = {
docs?: string
/** Admin-gated surface (shown with a lock hint; access enforced server-side). */
admin?: boolean
/**
* Beta (early-access) product — hidden from every nav, palette, discovery
* panel and search until the caller's ORG holds the `apps` beta through the
* enablement plane (kind `feature`, id `apps`) — the same self-service
* opt-in the Beta features module manages. Superadmins always see them, and
* the gate fails CLOSED: no enablement read, no beta surfaces.
*/
beta?: boolean
/**
* Per-brand scope — the brands whose console shows this entry (`entryInBrandScope`).
* OMIT for a brand-agnostic entry (the default: shown on every brand its category
@@ -3811,17 +3803,6 @@ export const catalogByCategory = (): { category: ProductCategory; entries: Catal
/** An admin-only (global / Hanzo-managed) entry — hidden from a customer's nav. */
export const isAdminEntry = (e: CatalogEntry): boolean => e.admin === true
/**
* A BETA entry — hidden until the org holds the beta flag (or is a superadmin).
*
* Beta is the COMPLEMENT of the launch set, not a per-entry stamp: we are
* launching with hanzo.chat, hanzo.app and the console, so everything outside
* `LAUNCH_PRODUCTS` is beta by default and a new catalog entry is hidden the
* day it lands. `beta: true` still forces the flag on for an entry inside the
* launch set, which is how a launch surface can ship dark.
*/
export const isBetaEntry = (e: CatalogEntry): boolean => e.beta === true || !isLaunchProduct(e.id)
/**
* Per-brand category scope — the ONE knob that makes each brand's console show
* the right surfaces. `hanzo` is the full AI cloud. The sovereign-chain brands
@@ -3855,13 +3836,7 @@ export const inBrand = (e: CatalogEntry): boolean =>
// former `BILLING_CENTER_ID`/`MARKETING_ID`/`ADS_ID`/`SOCIAL_ID` per-mode consts were
// collapsed into it (a name is a value in one namespace, no parallel id constants).
export const visibleCatalog = (
showAdmin: boolean,
enabled?: string[] | null,
// Fails CLOSED on purpose: a caller that has not asked the enablement plane
// does not show beta surfaces.
showBeta = false,
): CatalogEntry[] => {
export const visibleCatalog = (showAdmin: boolean, enabled?: string[] | null): CatalogEntry[] => {
// Product-shell face (billing / marketing / ads / social / sentry host, or an
// override): the SAME console image, scoped to ONE product FACE — its root module
// surfaced alone. Bypass the brand-category + entitlement scope so the face shows on
@@ -3877,7 +3852,7 @@ export const visibleCatalog = (
// belong to their face, not the general nav (e.g. the sentry panels are the o11y
// surfaces' Sentry twin, shown only on sentry.<brand>). marketing/ads/social carry
// NO `e.shell` (normal Apps products), so they ALSO show in the full console.
const byAdmin = filterBeta(showAdmin ? catalog : catalog.filter((e) => !isAdminEntry(e)), showBeta, showAdmin)
const byAdmin = (showAdmin ? catalog : catalog.filter((e) => !isAdminEntry(e)))
.filter((e) => !e.shell)
.filter(inBrand)
// ENTITLEMENT GATE (customer only): out-of-box an org sees ONLY the products it has
@@ -3891,9 +3866,8 @@ export const visibleCatalog = (
export const visibleCatalogByCategory = (
showAdmin: boolean,
enabled?: string[] | null,
showBeta = false,
): { category: ProductCategory; entries: CatalogEntry[] }[] => {
const visible = visibleCatalog(showAdmin, enabled, showBeta)
const visible = visibleCatalog(showAdmin, enabled)
// In a product-shell face the root module IS the whole catalog — surface it as a
// single group regardless of the brand's category order (its category may be
// outside the brand's normal set). ONE branch for EVERY face.
+3 -3
View File
@@ -87,14 +87,14 @@ function scoreDestination(q: string, d: Destination): number {
* deep sub-page jumps ("queues" Compute Tasks Queues). `showAdmin` gates
* admin-only surfaces so a customer can't jump to what they can't see.
*/
export function searchDestinations(query: string, showAdmin = true, enabled?: string[] | null, showBeta = false): Destination[] {
export function searchDestinations(query: string, showAdmin = true, enabled?: string[] | null): Destination[] {
const q = query.trim().toLowerCase()
// Scope to the visible catalog (brand + billing-only shell + entitlements), then
// gate admin — so ⌘K jumps match exactly what the nav shows (billing-only offers
// only billing; a customer only what their org has enabled). A TYPED query is
// DISCOVERY: the entitlement scope opens to the whole catalog — searching is
// for finding what you do not have yet — while admin and beta keep holding.
const all = destinationsFor(visibleCatalog(showAdmin, q ? null : enabled, showBeta), showAdmin)
// for finding what you do not have yet — while the admin gate keeps holding.
const all = destinationsFor(visibleCatalog(showAdmin, q ? null : enabled), showAdmin)
if (!q) return all.filter((d) => d.kind === 'product')
return all
.map((d) => ({ d, s: scoreDestination(q, d) }))
+11
View File
@@ -246,6 +246,17 @@ export const CLOUD_HEADS: readonly string[] = [
'gpus',
'fleet',
'clusters',
// Sandboxes (cloud apps/sandbox): the org's leased gVisor pods — lease/list/get/
// end, exec, fs, and the ticket that opens an interactive terminal
// (/v1/sandboxes[/:id[/exec|/fs|/terminal]]). Same gate as the rest: the handler
// resolves the org from the Bearer owner and answers 403 without one, and an id
// belonging to another org is a 404.
//
// The terminal's SOCKET does not come through here and cannot: a Next route
// handler proxies requests, not upgrades. The browser dials the API host
// directly, carrying the single-use ticket this proxy fetched for it — which is
// the whole reason the ticket exists.
'sandboxes',
// DO-native: virtual private clouds and managed load balancers — FULL CRUD
// (/v1/vpcs[/:id], /v1/balancers[/:id]).
'vpcs',