From e60215989ff6f2d015874c896c7933bea85b45a6 Mon Sep 17 00:00:00 2001 From: Chris Coveney Date: Wed, 4 Nov 2026 21:59:28 -0800 Subject: [PATCH 1/2] Show cloud usage as sessions Cloud model use is now metered per session rather than per call, and each session is recorded as one ledger row with the credits it cost. Update the Cloud Models pages to match. - Usage: drop the token stats, per-model rates and per-model chart, and chart sessions per day - Rewrite the copy for credits held by open sessions and for running out of credits - Call History becomes Session History: one row per session with when it was settled and the credits it cost; row details and the CSV export list whatever fields the API returns --- src/app/cloud-models/history-section.tsx | 102 +++++++---------------- src/app/cloud-models/layout.tsx | 5 +- src/app/cloud-models/shared.ts | 66 ++++++--------- src/app/cloud-models/usage-section.tsx | 78 ++++++----------- src/app/components/activity-chart.tsx | 4 +- src/app/components/app-shell.tsx | 2 +- 6 files changed, 88 insertions(+), 169 deletions(-) diff --git a/src/app/cloud-models/history-section.tsx b/src/app/cloud-models/history-section.tsx index fcf3f16..dde39ff 100644 --- a/src/app/cloud-models/history-section.tsx +++ b/src/app/cloud-models/history-section.tsx @@ -1,9 +1,8 @@ 'use client'; /** - * Call History: the account's view of the cloud ledger, one row per cloud - * model call — when, which model, who served it, tokens in, out and spent - * reasoning, and the credits it cost. + * Session History: the account's view of the cloud ledger, one row per cloud + * session — when it was settled and the credits it cost. * * No project column: projects are local to the desktop app, and the cloud * only ever sees an opaque project id it cannot name. Per-project cloud usage @@ -11,11 +10,10 @@ * * The ledger is a billing record, so this page is itemized rather than * summarized and can be exported. The API decides what each account may see - * (provider, cost, …) by leaving fields out, so the page shows whatever it - * sends: the Provider, Reasoning and Credits columns appear when any row has - * them, a row opens to list every field it came with, and the export has a - * column for every field. A call whose usage the provider never reported has - * estimated tokens (and so estimated credits), marked as such. + * by leaving fields out, so the page shows whatever it sends: the Credits and + * USD columns appear when any row has them, a row + * opens to list every field it came with, and the export has a column for + * every field. * * GET /v1/chat/ledger returns the whole range in one response, so rows are * paged here, in the browser. @@ -34,8 +32,6 @@ import { type LedgerEntry, count, credits, fetchLedger } from './shared'; const RANGES = [7, 30, 90] as const; const PAGE = 50; -const ESTIMATED = 'Estimated: the provider did not report usage for this call'; - const when = (iso: string) => new Date(iso).toLocaleString(undefined, { month: 'short', @@ -47,24 +43,14 @@ const when = (iso: string) => /** Labels for the fields a row's details list; any other field gets its key, * de-snaked. Listed in this order, then the rest as the API sent them. */ const FIELD_LABEL: Record = { - created_at: 'Time', - model: 'Model', - upstream_provider: 'Served by', - provider: 'Provider', - provider_model: 'Provider model', - streamed: 'Streamed', - prompt_tokens: 'Input tokens', - completion_tokens: 'Output tokens', - reasoning_tokens: 'Reasoning tokens', - reasoning_effort: 'Reasoning effort', + created_at: 'Settled', cost: 'Credits', - input_credits_per_mtok: 'Input rate (credits / 1M)', - output_credits_per_mtok: 'Output rate (credits / 1M)', - estimated: 'Estimated', + upstream_cost_micros: 'Upstream cost', + provider: 'Provider', plan: 'Plan', period: 'Period', project_id: 'Project id', - id: 'Call id', + id: 'Session id', }; const label = (key: string) => @@ -110,6 +96,8 @@ const has = (entries: LedgerEntry[], key: string) => const TH = 'font-medium py-2 px-4 first:pl-0 last:pr-0 whitespace-nowrap'; const TD = 'py-2.5 px-4 first:pl-0 last:pr-0'; +const usd = (micros: number) => `$${(micros / 1_000_000).toFixed(micros < 10_000 ? 4 : 2)}`; + function Details({ entry }: { entry: LedgerEntry }) { return (
@@ -146,12 +134,12 @@ export default function CloudHistorySection({ user }: { user: any }) { setLoadedDays(days); setShown(PAGE); } else { - setError('Could not load your call history.'); + setError('Could not load your session history.'); } }) .catch((err) => { console.error('Failed to load cloud ledger:', err); - if (!cancelled) setError('Could not load your call history.'); + if (!cancelled) setError('Could not load your session history.'); }); return () => { cancelled = true; @@ -162,21 +150,18 @@ export default function CloudHistorySection({ user }: { user: any }) { () => (entries ?? []).reduce( (t, e) => ({ - input: t.input + e.prompt_tokens, - output: t.output + e.completion_tokens, - reasoning: t.reasoning + (e.reasoning_tokens ?? 0), cost: t.cost + (e.cost ?? 0), + usd: t.usd + (e.upstream_cost_micros ?? 0), }), - { input: 0, output: 0, reasoning: 0, cost: 0 } + { cost: 0, usd: 0 } ), [entries] ); const rows = entries ?? []; - const showProvider = has(rows, 'upstream_provider') || has(rows, 'provider'); - const showReasoning = has(rows, 'reasoning_tokens') || has(rows, 'reasoning_effort'); const showCost = has(rows, 'cost'); - const columns = 5 + Number(showProvider) + Number(showReasoning) + Number(showCost); + const showUsd = has(rows, 'upstream_cost_micros'); + const columns = 2 + Number(showCost) + Number(showUsd); const exportCsv = () => { if (!entries?.length) return; @@ -184,15 +169,15 @@ export default function CloudHistorySection({ user }: { user: any }) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `smartloop-cloud-calls-${loadedDays}d.csv`; + a.download = `smartloop-cloud-sessions-${loadedDays}d.csv`; a.click(); URL.revokeObjectURL(url); }; return (
@@ -234,7 +219,7 @@ export default function CloudHistorySection({ user }: { user: any }) { - No cloud model calls in the last {loadedDays} days. + No cloud sessions in the last {loadedDays} days. } /> @@ -245,19 +230,14 @@ export default function CloudHistorySection({ user }: { user: any }) { - Time - Model - {showProvider && Provider} - In - Out - {showReasoning && Reasoning} + Settled {showCost && Credits} + {showUsd && Upstream} {entries.slice(0, shown).map((e) => { const isOpen = open === e.id; - const estimated = e.estimated ? ESTIMATED : undefined; return ( {when(e.created_at)} - {e.model} - {showProvider && ( - - {e.upstream_provider ?? e.provider ?? '—'} + {showCost && ( + + {e.cost == null ? '—' : credits(e.cost)} )} - - {e.estimated && '~'}{count(e.prompt_tokens)} - - - {e.estimated && '~'}{count(e.completion_tokens)} - - {showReasoning && ( + {showUsd && ( - {e.reasoning_tokens != null ? count(e.reasoning_tokens) : '—'} - {e.reasoning_effort && · {e.reasoning_effort}} - - )} - {showCost && ( - - {e.cost === 0 ? Free : e.cost == null ? '—' : `${e.estimated ? '~' : ''}${credits(e.cost)}`} + {e.upstream_cost_micros == null ? '—' : usd(e.upstream_cost_micros)} )} @@ -312,13 +276,11 @@ export default function CloudHistorySection({ user }: { user: any }) { - - {count(entries.length)} calls + + {count(entries.length)} {entries.length === 1 ? 'session' : 'sessions'} - {count(totals.input)} - {count(totals.output)} - {showReasoning && {count(totals.reasoning)}} {showCost && {credits(totals.cost)}} + {showUsd && {usd(totals.usd)}} diff --git a/src/app/cloud-models/layout.tsx b/src/app/cloud-models/layout.tsx index 9e5f8d8..e29a062 100644 --- a/src/app/cloud-models/layout.tsx +++ b/src/app/cloud-models/layout.tsx @@ -2,8 +2,9 @@ /** * The frame for every Cloud Models page. A layout stays mounted while you move - * between its child routes, so going from Usage to Call History swaps only the - * section: the sidebar and header stay put and /users/me is not fetched again. + * between its child routes, so going from Usage to Session History swaps only + * the section: the sidebar and header stay put and /users/me is not fetched + * again. * * The frame and the pages render before the user has loaded. Each section * shows its headings straight away and fills in its numbers as they arrive, diff --git a/src/app/cloud-models/shared.ts b/src/app/cloud-models/shared.ts index 6928c4b..2cf7137 100644 --- a/src/app/cloud-models/shared.ts +++ b/src/app/cloud-models/shared.ts @@ -1,18 +1,17 @@ /** * What the Cloud Models pages read from the API, and how they format it. * - * Cloud (remote) models run on Smartloop's provider accounts behind - * POST /v1/chat/completions, so every call is metered against a monthly, - * per-plan allowance of credits and recorded in the cloud ledger - * (GET /v1/chat/ledger). Each model has a rate card in credits per million - * tokens; what that costs us upstream stays server-side. + * Cloud model use is metered in sessions. Each session holds part of the + * account's monthly allowance of credits while it is open; once it ends, what + * it used is charged and recorded as one row in the cloud ledger + * (GET /v1/chat/ledger). The ledger has no per-model or token detail: one + * charge per session. * - * Users see credits: what is left of the month, what each call cost, and each - * model's rate. The API sends the month's credit counts to superusers only for - * now, so for everyone else they are null and the usage row falls back to - * percentages. Which upstream served a call and what it cost in USD are - * superuser-only too: the API sends them as null to everyone else, and the - * pages show a field only when it has a value. + * Users see credits: what is left of the month and what each session cost. + * Some accounts don't get the month's credit counts, so for them they are null + * and the usage row falls back to percentages. Other fields are withheld the + * same way: the API sends them as null, and the pages show a field only when + * it has a value. */ import { userService } from '@/app/services/user.service'; @@ -21,22 +20,21 @@ import { API_ENDPOINT } from '@/app/constants'; export interface CloudQuota { /** Credits this month. null when unmetered (admin accounts) or withheld. */ limit: number | null; - /** Credits spent on settled calls. null when withheld. */ + /** Credits charged for settled sessions. null when withheld. */ used: number | null; /** Credits left. null when unmetered or withheld. */ remaining: number | null; - /** Credits held by calls in flight. null when withheld. */ + /** Credits held by open sessions. null when withheld. */ reserved: number | null; - /** 0–100 of the month's credits spent on settled calls. null means + /** 0–100 of the month's credits charged for settled sessions. null means * unmetered (admin accounts). */ percent_used: number | null; - /** 0–100 a new call can still use, after settled spend and what calls in - * flight are holding. null when unmetered. */ + /** 0–100 a new session can still hold, after settled spend and what open + * sessions are holding. null when unmetered. */ percent_available: number | null; - /** Calls still running, each holding its worst case until it settles. */ + /** Open sessions, each holding its budget until it is settled. */ in_flight: number; - prompt_tokens: number; - completion_tokens: number; + /** Sessions settled this month. */ requests: number; /** YYYY-MM. */ period: string; @@ -53,32 +51,20 @@ export interface CloudStatus { quota: CloudQuota; } +/** One settled session: when it was charged and what it cost. */ export interface LedgerEntry { id: string; + /** When the session was settled, after it ended. */ created_at: string; - /** Catalog name, e.g. gpt-oss-20b. */ - model: string; project_id: string | null; - streamed: boolean; - prompt_tokens: number; - completion_tokens: number; - /** The provider never reported usage for this call, so its tokens are our - * estimate from the text in and out. */ - estimated: boolean; - /** Credits the call cost. */ + /** Credits the session cost. */ cost: number | null; period: string; - // Sent with a value only to accounts allowed to see them (the upstream and - // USD fields: superusers): shown when present, never assumed. - /** Who served the call through OpenRouter, e.g. "CoreWeave". */ - upstream_provider?: string | null; - /** Our upstream (e.g. openrouter) and the model's id there. */ + // Sent with a value only to accounts allowed to see them: shown when + // present, never assumed. provider?: string | null; - provider_model?: string | null; - /** How many of completion_tokens went to reasoning, when reported. */ - reasoning_tokens?: number | null; - /** The reasoning effort sent upstream. */ - reasoning_effort?: string | null; + /** In micro-USD. */ + upstream_cost_micros?: number | null; /** Anything else the API adds to a row: listed in its details as is. */ [field: string]: unknown; } @@ -91,8 +77,8 @@ export const PLAN_LABEL: Record = { export const count = (n: number) => n.toLocaleString(); -/** A credit amount that may be a fraction: a small call costs well under one - * credit, so a fixed number of decimals would show most rows as 0. */ +/** A credit amount that may be a fraction: a short session costs well under + * one credit, so a fixed number of decimals would show most rows as 0. */ export function credits(n: number): string { if (n === 0) return '0'; if (n < 0.01) return '<0.01'; diff --git a/src/app/cloud-models/usage-section.tsx b/src/app/cloud-models/usage-section.tsx index 34cc5a2..c29552f 100644 --- a/src/app/cloud-models/usage-section.tsx +++ b/src/app/cloud-models/usage-section.tsx @@ -1,20 +1,19 @@ 'use client'; /** - * Cloud model usage: how much of this month's credits are spent, what they - * were spent on, and which cloud models the account can run. + * Cloud model usage: how much of this month's credits are spent, how many + * sessions spent them, and which cloud models the account can run. * * Laid out like the credits menu in ScreenForge's agent panel: the balance - * first with the plan beside it, then what it is out of, then a bar. Each - * model shows its rate, so the cheaper ones are easy to pick. An unmetered - * (admin) account gets the same row with what it used and "Unlimited". Until - * the API sends credit counts to every account, the row falls back to - * percentages. + * first with the plan beside it, then what it is out of, then a bar. An + * unmetered (admin) account gets the same row with what it used and + * "Unlimited". Until the API sends credit counts to every account, the row + * falls back to percentages. * * GET /v1/chat/status carries the quota and, when cloud models can't run, why * not; the copy follows that reason rather than re-deriving it from numbers. - * Models rated at 0 credits are never blocked, so "out of credits" does not mean - * cloud models stop entirely; the copy says so. + * A session can't start without credits to hold, so "out of credits" stops + * every cloud model until the reset. * * Cloud models are a Pro feature: there are no free credits, so a free * account gets the case for upgrading and the catalog, with nothing to meter. @@ -59,15 +58,6 @@ function Pending() { return ; } -function Stat({ label, value }: { label: string; value: string }) { - return ( - -
{label}
-
{value}
-
- ); -} - /** The plan beside the balance, as in the desktop app's credits menu. */ function PlanBadge({ label, accent }: { label: string; accent?: boolean }) { return ( @@ -123,7 +113,7 @@ function CreditsRow({ status }: { status: CloudStatus }) { return ( } /> @@ -132,7 +122,7 @@ function CreditsRow({ status }: { status: CloudStatus }) { } const used = quota.percent_used; - // What calls in flight hold sits between settled spend and what's left. + // What open sessions hold sits between settled spend and what's left. const held = Math.max(0, 100 - quota.percent_available - used); const spent = reason === 'quota_exceeded'; const low = reason === 'quota_low'; @@ -175,17 +165,18 @@ function CreditsRow({ status }: { status: CloudStatus }) {
{quota.in_flight > 0 && (

- {quota.in_flight === 1 ? 'A call is still running. ' : `${count(quota.in_flight)} calls are still running. `} + {quota.in_flight === 1 ? 'A cloud session is open. ' : `${count(quota.in_flight)} cloud sessions are open. `} The lighter part of the bar{quota.reserved ? ` (${credits(quota.reserved)} credits)` : ''} is held for{' '} - {quota.in_flight === 1 ? 'it until it finishes' : 'them until they finish'}. + {quota.in_flight === 1 ? 'it' : 'them'} until {quota.in_flight === 1 ? 'it ends' : 'they end'}; only what + was used is charged, and the rest comes back.

)} {(spent || low) && (

{spent - ? 'Paid cloud models are paused until the reset.' - : 'There are too few credits left for another call to a paid cloud model, so they are paused until the reset.'}{' '} - Free cloud models and everything that runs on your device keep working. + ? 'Cloud models are paused until the reset.' + : 'There are too few credits left for another cloud session, so cloud models are paused until the reset.'}{' '} + Everything that runs on your device keeps working.

)} @@ -193,16 +184,6 @@ function CreditsRow({ status }: { status: CloudStatus }) { ); } -/** "12 / 210 credits per 1M tokens in / out". Absolute, not relative to a - * default model: input and output rates rank the models differently. */ -function rateLabel(m: ModelEntry): string | null { - const input = m.input_credits_per_mtok; - const output = m.output_credits_per_mtok; - if (input == null || output == null) return null; - if (input === 0 && output === 0) return 'Free: uses no credits'; - return `${count(input)} / ${count(output)} credits per 1M tokens in / out`; -} - /** "131K" / "900K": token limits read at a glance. */ const tokensShort = (n: number) => n >= 1000 ? `${Math.round(n / 1000).toLocaleString()}K` : n.toLocaleString(); @@ -252,7 +233,7 @@ function ModelList({ models, locked }: { models: ModelEntry[]; locked: boolean } {m.context_length ? ` · ${contextLabel(m.context_length)}` : ''} {m.access_level === 'internal' ? ' · Internal' : ''} - {[rateLabel(m), limitsLabel(m), reasoningLabel(m)].filter(Boolean).map((line) => ( + {[limitsLabel(m), reasoningLabel(m)].filter(Boolean).map((line) => (
{line}
))} {(m.capabilities?.length ?? 0) > 0 && ( @@ -322,7 +303,6 @@ export default function CloudUsageSection({ user }: { user: any }) { const ledger = useLoad(user, () => fetchLedger(14)); const models = useLoad(user, fetchModels); - const quota = status?.quota; const failed = status === null || ledger === null || models === null; // Free accounts get no credits: cloud models are a Pro feature. Until status @@ -335,7 +315,7 @@ export default function CloudUsageSection({ user }: { user: any }) { return (
{needsPro && ( : status === undefined && )} - {!needsPro && status !== null && ( - - - - - - - - )} - {!needsPro && ( - Last 14 days, by model. Every call is itemized in{' '} + Last 14 days. Every session and what it cost is itemized in{' '} - Call History + Session History . @@ -378,9 +348,9 @@ export default function CloudUsageSection({ user }: { user: any }) { stacked > ({ timestamp: e.created_at, action: e.model }))} - labelFor={(name) => models?.find((m) => m.name === name)?.display_name || name} - emptyText="No cloud model calls in the last 14 days." + entries={(ledger ?? []).map((e) => ({ timestamp: e.created_at, action: 'session' }))} + labelFor={() => 'Sessions'} + emptyText="No cloud sessions in the last 14 days." loading={ledger === undefined} /> diff --git a/src/app/components/activity-chart.tsx b/src/app/components/activity-chart.tsx index 8ea3fb8..8922753 100644 --- a/src/app/components/activity-chart.tsx +++ b/src/app/components/activity-chart.tsx @@ -50,8 +50,8 @@ const defaultLabel = (action: string) => export default function ActivityChart({ entries, - // The Cloud Models page charts calls per model, where the "action" is a - // catalog name that must be shown as written. + // Callers whose "action" isn't an access-log category name it themselves + // (the Cloud Models page charts sessions). labelFor = defaultLabel, emptyText = `No recorded activity in the last ${DAYS} days.`, // Draw the empty frame instead of the empty sentence, so the chart holds its diff --git a/src/app/components/app-shell.tsx b/src/app/components/app-shell.tsx index 88dc6a2..a70d7fd 100644 --- a/src/app/components/app-shell.tsx +++ b/src/app/components/app-shell.tsx @@ -49,7 +49,7 @@ const NAV_GROUPS: { adminOnly: true, items: [ { label: 'Usage', path: '/cloud-models/usage', icon: CloudOutlinedIcon }, - { label: 'Call History', path: '/cloud-models/history', icon: HistoryIcon }, + { label: 'Session History', path: '/cloud-models/history', icon: HistoryIcon }, ], }, { From 021e53d00e60a68641a1bf210a55edf1840620a0 Mon Sep 17 00:00:00 2001 From: Chris Coveney Date: Wed, 4 Nov 2026 21:59:28 -0800 Subject: [PATCH 2/2] Open the Cloud Models pages to every account Cloud models are available on every paid plan, so the sidebar group and the pages no longer send other accounts back to the dashboard. - Remove the admin-only flag on the Cloud Models nav group - Remove the AdminOnly wrapper from the Cloud Models layout - Fields the API withholds from an account stay hidden, since the pages only show fields that have a value --- src/app/cloud-models/admin-only.tsx | 22 ---------------------- src/app/cloud-models/layout.tsx | 5 +---- src/app/components/app-shell.tsx | 11 ++++------- 3 files changed, 5 insertions(+), 33 deletions(-) delete mode 100644 src/app/cloud-models/admin-only.tsx diff --git a/src/app/cloud-models/admin-only.tsx b/src/app/cloud-models/admin-only.tsx deleted file mode 100644 index d461457..0000000 --- a/src/app/cloud-models/admin-only.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'use client'; - -/** - * TEMP: cloud models are superuser-only in the API until launch - * (REMOTE_MODELS_SUPERUSER_ONLY), so these pages are too. Anyone else who - * lands on one by URL goes back to the dashboard. Remove at launch. - * - * While the user is still loading the children render, so the page can show - * its headings. They carry no data: the API refuses non-admins anyway. - */ - -import { useEffect, type ReactNode } from 'react'; -import { useRouter } from 'next/navigation'; - -export default function AdminOnly({ user, children }: { user: any; children: ReactNode }) { - const router = useRouter(); - const denied = !!user && !user.admin; - useEffect(() => { - if (denied) router.replace('/dashboard'); - }, [denied, router]); - return denied ? null : <>{children}; -} diff --git a/src/app/cloud-models/layout.tsx b/src/app/cloud-models/layout.tsx index e29a062..aa414a5 100644 --- a/src/app/cloud-models/layout.tsx +++ b/src/app/cloud-models/layout.tsx @@ -14,16 +14,13 @@ import type { ReactNode } from 'react'; import { useAuth } from '@/app/hooks/useAuth'; import SettingsShell from '@/app/settings/settings-shell'; -import AdminOnly from '@/app/cloud-models/admin-only'; import { CloudUserContext } from '@/app/cloud-models/user-context'; export default function CloudModelsLayout({ children }: { children: ReactNode }) { const [user] = useAuth(); return ( - - {children} - + {children} ); } diff --git a/src/app/components/app-shell.tsx b/src/app/components/app-shell.tsx index a70d7fd..4619612 100644 --- a/src/app/components/app-shell.tsx +++ b/src/app/components/app-shell.tsx @@ -33,7 +33,6 @@ const SIDEBAR_WIDTH = 248; // it does not need a header of its own. const NAV_GROUPS: { heading: string | null; - adminOnly?: boolean; items: { label: string; path: string; icon: typeof TravelExploreIcon }[]; }[] = [ { @@ -45,8 +44,6 @@ const NAV_GROUPS: { }, { heading: 'Cloud Models', - // TEMP: cloud models are superuser-only in the API until launch. - adminOnly: true, items: [ { label: 'Usage', path: '/cloud-models/usage', icon: CloudOutlinedIcon }, { label: 'Session History', path: '/cloud-models/history', icon: HistoryIcon }, @@ -66,7 +63,7 @@ const NAV_GROUPS: { }, ]; -function SidebarContent({ isAdmin, onNavigate }: { isAdmin: boolean; onNavigate?: () => void }) { +function SidebarContent({ onNavigate }: { onNavigate?: () => void }) { const router = useRouter(); const pathname = usePathname(); const { theme, toggleTheme } = useTheme(); @@ -83,7 +80,7 @@ function SidebarContent({ isAdmin, onNavigate }: { isAdmin: boolean; onNavigate? - {NAV_GROUPS.filter((g) => isAdmin || !g.adminOnly).map((group, gi) => ( + {NAV_GROUPS.map((group, gi) => ( {group.heading && (
@@ -145,7 +142,7 @@ export default function AppShell({ user, children }: { user?: any; children: Rea className="hidden md:block fixed inset-y-0 left-0 z-30" sx={{ width: SIDEBAR_WIDTH, borderRight: '1px solid var(--border)', bgcolor: 'var(--surface)' }} > - + {/* Mobile drawer */} @@ -161,7 +158,7 @@ export default function AppShell({ user, children }: { user?: any; children: Rea className="md:hidden fixed inset-y-0 left-0 z-50" sx={{ width: SIDEBAR_WIDTH, borderRight: '1px solid var(--border)', bgcolor: 'var(--surface)' }} > - setDrawerOpen(false)} /> + setDrawerOpen(false)} /> )}