Skip to content

fix(app-shell): read the single AI quota pool and draw one ring (objectui#8524) - #10911

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-8524-ai-usage-single-pool
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-8524-ai-usage-single-pool

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #8524

⏸️ Parked on an external gate, not a defect of this PR: the required Spec Main Shape Gate fails every objectui merge group (report-chart-query-spec-parity.test.ts line 747, TS1360, against objectstack df3ba164a588). Anchor card: #10916. This PR is re-queued by the claiming seat when that card closes.

Clause-②: no

What was wrong

After objectstack-ai/cloud#2144 the tenant runtime's GET /api/v1/ai/usage answers { pool, breakdown }: one AI quota pool, plus an observation-only split of that same pool. useAiUsage only parsed the retired per-meter { meters: { build, dataChat } } answer, so its parser returned null and AiUsageIndicator rendered nothing. No ring, no error.

Producer read at cloud main 96eb092, packages/objectos-runtime/src/ai-usage-endpoint-plugin.ts (symbols AiUsageIndicator, AiUsageBreakdownFractions, AiUsageResponse, buildAiUsageResponse, unknownResponse):

  • pool carries planType, fraction (number or null), unmetered, resetKind, resetsAt, upgrade, topUp. These are the same fields each old meter carried.
  • At that sha breakdown is always an object whose members are number or null. Every error path answers 200 with a neutral pool (fraction: null) and both members null.
  • Cloud ADR-0015 makes the build / dataChat split read-only statistics that no quota decision uses. cloud#2133 lets the endpoint keep breakdown but does not require it, so its absence is a normal reading. The triage comment on the card says the same.

What changed

  • useAiUsage reads { pool, breakdown? } and only that shape.
    • pool must carry every declared field with its primitive type. resetKind and planType are checked as strings, so a reset kind this build does not know still renders without a reset line (the objectui#7371 behaviour, still pinned).
    • A missing breakdown, or one with null members, is a normal reading and never the parse-failure branch.
    • Any other answer, the retired { meters } one included, gives usage: null and now also sets error. There is no dual-shape read.
  • AiUsageIndicator draws ONE ring from pool.
    • The status, the reset line and the upgrade / top-up CTA all read the pool.
    • The split appears as text in the popover under the pool's row: a "Used so far" heading, then e.g. Build 30% and Ask 20%, each a percentage of the same pool. It is shown only when at least one member is a number, and it is never a second ring.
    • The CTA's test id is now ai-usage-cta.
  • i18n: one new key, console.ai.usage.breakdownTitle, in every locale pack. The split rows reuse meterBuild / meterAsk.
  • Changeset: .changeset/8524-ai-usage-single-pool.md declares a patch for @object-ui/app-shell and @object-ui/i18n.

File surface is the claim's: the hook, the component, their two test files, the ten locale packs and one changeset. The hooks barrel is untouched (see Acceptance notes).

Tests

packages/app-shell/src/hooks/__tests__/useAiUsage.test.ts:

  • the new shape parses to { pool, breakdown };
  • breakdown absent parses to { pool } with no error;
  • breakdown with both members null parses as a normal reading;
  • the retired { meters } answer gives usage: null with error set (the strict reader, pinned);
  • a breakdown member that is not a fraction gives usage: null.

packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx:

  • ONE ring in the trigger even when the split is measured;
  • the split is listed as text with no ring inside it, and the popover holds exactly one ring;
  • both members null gives one ring and no split;
  • breakdown absent gives one ring and no split;
  • unmetered or null-fraction pool renders nothing;
  • low hint, CTA and the weekly reset cases, ported to the pool;
  • through the REAL hook: a { pool } answer with no breakdown renders the ring, and the { meters } answer renders nothing once the hook reports its error.

Reverse verification (after committing the fix; mutation and restore scripted with a trap): restoring the base versions of useAiUsage.ts and AiUsageIndicator.tsx under the new tests gives Test Files 2 failed (2), Tests 20 failed | 4 passed (24). The 4 that stay green are the shape-independent cases: null usage renders nothing, non-2xx, inert without apiBase, and refetch on nudge. The restore was proven by HEAD blob hashes and an empty git diff HEAD.

Local verification

Final reading at 12e1c1d. That head is this branch after merging origin/main three times; the merges brought no change to the files this PR touches. Each exit code was captured to a file before any pipe. The full app-shell suite ran once, at fd6907c (the fix commit): Test Files 843 passed | 1 skipped (844), Tests 8673 passed | 9 skipped. It was not re-run at the final head, because CI runs it.

Gate (at 12e1c1d) Exit
node scripts/check-changeset-presence.mjs 0
pnpm changeset:check (fixed group + no-major) 0
pnpm check:i18n-keys 0
pnpm check:i18n-drift 0
pnpm check:i18n-dead-keys 0
pnpm check:control-bytes 0
pnpm check:new-line-citations (VERDICT 0 new citations) 0
pnpm check:changeset-claims 0
pnpm check:pending-changeset-literals 0
pnpm check:vi-mock-specifiers / vi-mock-inherit / vi-mock-override-shape 0 / 0 / 0
pnpm check:test-path-roots 0
pnpm check:unreferenced-sources / phantom-deps / self-import 0 / 0 / 0
turbo run build --filter=@object-ui/app-shell^... (28/28, all cache hits) then pnpm --filter @object-ui/app-shell run type-check (verify lock) 0
vitest run on useAiUsage.test.ts + AiUsageIndicator.test.tsx (verify lock): 2 files, 24 tests passed 0
eslint --format json on the 14 .ts / .tsx files in this diff: 0 errors, 2 warnings that were already there 0
pnpm check:eager-locale-catalogues 2, PREREQUISITE NOT MET (NOT MEASURED)

Also run at a5d7c25, after the first merge (which touched the locale packs): the packages/i18n/ suite plus the two touched tests plus the two ChatDock tests gave 76 files and 1234 tests passed, exit 0. tsc -p tsconfig.test.json --listFilesOnly confirms the package type-check compiles both touched test files.

Lint was narrowed to the diff's .ts / .tsx files. The narrowing is safe because eslint.config.js sets no parserOptions / project / projectService / type-checked preset (zero hits, with a lit control), has no import/* plugin rules, and none of eslint-rules/* reads another file. Every rule therefore judges one file, and this diff cannot move a verdict on an untouched file. The count comes from --format json: 14 files, 0 errors, 2 warnings. Both warnings are react-hooks/set-state-in-effect on effects this diff does not change, and linting the base blobs of those two files shows the same two warnings.

NOT MEASURED:

  • check:eager-locale-catalogues: PREREQUISITE NOT MET, because it needs a built console bundle (apps/console/dist/eager-closure.json). CI builds it.
  • A browser run against a live cloud runtime: no cloud backend in this container.

Acceptance notes

  • Split placement: in the popover, which is the indicator's detail view, rather than in a native title tooltip. The card allows "a tooltip or a detail view", and a title tooltip never shows on touch devices.
  • Strict breakdown, decided by the PM on the dev report (a PM decision, not a maintainer ruling: option A, keep strict): a breakdown that is present but malformed is rejected like any other unreadable answer. error is set and the ring hides. "Malformed" means not an object, a member that is neither a number nor null, or a literal breakdown: null, which the producer never sends. The alternative, dropping a malformed split and keeping the ring, would be exactly the tolerant read the card's routing constraint forbids, and it would hide a future rename on the producer side. Only an absent breakdown, or one with null members, means "no split".
  • error on an unreadable answer: the hook now sets error when an answer does not parse. Before, it returned usage: null with no error. No UI reads error today.
  • Type names: AiMeterUsage keeps its name and now documents the pool. The new AiUsageBreakdown lives in the hook file. Neither change touches packages/app-shell/src/hooks/index.ts, which is outside the claimed surface. The package entry src/index.ts re-exports none of these types, hence Clause-②: no.
  • Delivery order: the cloud tenant image bundles objectui at cloud's .objectui-sha (the overlay layer in apps/objectos/Dockerfile). So this reader ships with a runtime that already serves { pool, breakdown }. The one path where it could meet a { meters } runtime is a hotfix branch that moves the pin without cloud#2144. That path is noted here and deliberately not answered with a dual read.
  • Out of scope: nothing found that qualifies for a new card.

Session: https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1


Generated by Claude Code

The cloud usage endpoint now answers { pool, breakdown } (one quota pool plus
an observation-only split). useAiUsage parsed only the retired per-meter
{ meters: { build, dataChat } } answer, so it returned null and the ChatDock
indicator disappeared without any error.

- useAiUsage reads { pool, breakdown? } strictly. An absent breakdown is a
  normal reading; the retired { meters } answer parses to null and sets error.
- AiUsageIndicator draws ONE ring from pool. The split is listed as text in
  the popover (share of the same pool), only when a member was measured.
- New console.ai.usage.breakdownTitle row in every locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1
…ectui#8524)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1
…dy the indicator guard

Comment wording only in useAiUsage (the optional breakdown comes from cloud#2133,
not ADR-0015), and a behaviour-identical early-return guard in AiUsageIndicator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 2 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6661-app-launcher-nav-menu-renderers.md

  • names en.ts → packages/i18n/src/locales/en.ts — edited by this change

    Three new strings — the launcher's and the menu's accessible names, and the menu's empty state — are declared under console.nav in en.ts and its nine sibling packs. An inline defaultValue alone is not a fix: it renders English at one call site and leaves the string untranslatable everywhere (objectui#3517).

.changeset/7371-ai-usage-indicator-weekly-reset.md

  • names packages/app-shell/src/hooks/useAiUsage.ts → packages/app-shell/src/hooks/useAiUsage.ts — edited by this change

    AiUsageResetKind (packages/app-shell/src/hooks/useAiUsage.ts) gains the 'weekly' member; resetsAt was already string | null and needed no shape change.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 8522396c0 (merge-base with origin/main): 14 file(s) changed outside .changeset/, read against 1669 pending declaration(s) that publish a body (2263 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3096.4 KB 3104.5 KB
Main entry chunk (gzip) 148.7 KB 350 KB
Entry file index-DDpKSJvH.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.58KB 6.17KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.70KB 2.23KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 557.59KB 133.60KB
core (index.js) 9.93KB 3.94KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 226.44KB 63.00KB
fields (index.js) 261.01KB 66.28KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.32KB 11.09KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 51.96KB 14.83KB
plugin-charts (index.js) 83.99KB 22.86KB
plugin-chatbot (index.js) 197.67KB 46.90KB
plugin-dashboard (index.js) 136.93KB 36.48KB
plugin-designer (index.js) 215.03KB 44.21KB
plugin-detail (index.js) 233.48KB 61.79KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 161.21KB 41.41KB
plugin-gantt (index.js) 170.35KB 42.19KB
plugin-grid (index.js) 228.33KB 62.59KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 115.86KB 28.64KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.17KB 12.20KB
plugin-timeline (index.js) 31.00KB 9.09KB
plugin-tree (index.js) 11.21KB 3.89KB
plugin-view (index.js) 88.55KB 22.21KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.16KB 39.05KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 7.50KB 3.05KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.16KB 2.71KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.22KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 3.83KB 1.49KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.26KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.27KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 12e1c1d5dcd1f9e38b9451f3eb8d55c0f1f2d2b9
Local-runs: none

Inputs read: card #8524 (body and all five comments), PR #10911 (body, 15-file list, net diff against main), the 43 check-runs on the head, and the producer at cloud origin/main 72857a1, packages/objectos-runtime/src/ai-usage-endpoint-plugin.ts. Nothing was built, run or re-run.

① Derived judgments

Accept set of parseUsage (the consumer's read of GET /api/v1/ai/usage), each change against the producer's AiUsageIndicator, AiUsageBreakdownFractions, AiUsageResponse, buildAiUsageResponse and unknownResponse:

  1. Accepts { pool } only when pool carries all seven declared fields by primitive type (planType string, fraction finite number or null, unmetered boolean, resetKind string, resetsAt string or null, upgrade boolean, topUp boolean) — RIGHT. Both producer paths (buildUsageIndicator, unknownIndicator) emit exactly these seven; fraction is clamped 0..1 or null; JSON carries no NaN or Infinity, so the finite check excludes nothing the wire can send. The two unions are checked as strings, so the objectui#7371 unknown-resetKind behaviour (no reset line, no failed parse) survives — RIGHT.
  2. Accepts breakdown absent and returns { pool } — RIGHT. The card and the triage ruling make absence a normal reading, never the null branch; the producer at this sha always sends the object, so the accept set exceeds the wire by exactly the optionality the ruling grants. Pinned in the hook test, the component test and through the real hook.
  3. Accepts breakdown: { build, dataChat } with each member a finite number or null — RIGHT against AiUsageBreakdownFractions and shareOfCap (clamped 0..1 or null; unknownResponse sends both null, which the reader treats as "no split").
  4. Rejects the retired { meters: { build, dataChat } } answer: usage: null, error set — RIGHT. The triage seat's routing constraint (no tolerant dual-shape read) holds; pinned in both test files and through the real hook, and the reverse verification in the PR body shows the old reader fails 20 of the 24 new cases.
  5. Rejects a breakdown that is present but malformed (not an object, the literal null, or a member that is neither a number nor null): usage: null, error set — RIGHT under the card's contract-first rule. The producer's breakdown is a non-nullable object whose members are number | null; anything else is a wire change and should surface as a failed parse, not as a silently dropped section. The producer never sends breakdown: null.
  6. Extra keys on the payload or on pool are ignored (only the seven fields are checked, pool is passed through by reference) — RIGHT; an additive producer change cannot blank the ring.

Public surface and behaviour:

  1. AiUsageResponse moves from { meters } to { pool, breakdown? }; AiUsageBreakdown is a new exported type; AiMeterUsage keeps its fields. All three live in packages/app-shell/src/hooks/useAiUsage.ts; neither hooks/index.ts nor the package entry src/index.ts is in the diff. RIGHT as a patch-level change on the premise judged in ②.
  2. useAiUsage now sets error (Unrecognized AI usage response shape) when an answer does not parse, where before it left usage: null and cleared error — RIGHT; additive, and it turns the next wire change into an observable failed parse instead of silence. No UI reads error.
  3. AiUsageIndicator draws ONE MeterRing in the trigger and ONE in the popover, both from pool; status, reset line and the upgrade / top-up CTA read pool; the component renders nothing when pool.unmetered or pool.fraction === null (the producer sets fraction: null whenever unmetered) — RIGHT. The split is text rows under data-testid="ai-usage-breakdown" (meterBuild / meterAsk plus formatNumber(..., { style: 'percent' })), shown only when at least one member is a number, and holds no ring — RIGHT against the card ("a tooltip or a detail view, never as a second gauge") and D5 (percentages of the one pool, never a token count). The early return sits after every hook call and no hook follows it — RIGHT (Lint success on the head).
  4. data-testid ai-usage-cta-build / ai-usage-cta-dataChat collapses to ai-usage-cta — RIGHT; a test id, not a public surface; Build & E2E and Live E2E are success on the head.
  5. i18n: one new key console.ai.usage.breakdownTitle inside console.ai.usage in all ten locale packs — RIGHT; additive, the rows reuse the existing meterBuild / meterAsk keys.

Shipped prose — .changeset/8524-ai-usage-single-pool.md, every factual sentence:

  • "Fix the ChatDock AI usage indicator going blank against the single-pool AI usage endpoint (objectui#8524)." — RIGHT (the card's symptom).
  • "The cloud GET /api/v1/ai/usage endpoint now answers { pool, breakdown }: one quota pool, plus breakdown, a read-only split of that same pool into app-building (build) and data Q&A (dataChat)." — RIGHT against AiUsageResponse and its docblock ("OBSERVATION-ONLY split of that same pool into app-building vs data Q&A").
  • "useAiUsage only read the retired per-meter { meters: { build, dataChat } } answer, so it returned null and AiUsageIndicator rendered nothing, with no error." — RIGHT against the removed lines (isMeter on meters.build / meters.dataChat, then setError(undefined)).
  • "useAiUsage reads { pool, breakdown? } and only that shape. A missing breakdown, or one whose members are null, is a normal reading. Any other answer — the retired { meters } one included — leaves usage null and now also sets error." — RIGHT against the new parseUsage and the .then branch.
  • "The hook's AiUsageResponse type is now { pool, breakdown? }, with the new AiUsageBreakdown type for the split; AiMeterUsage keeps its fields and describes the pool." — RIGHT.
  • "AiUsageIndicator draws one ring from pool. When a breakdown member is a number, the popover lists it under the pool's row as a percentage of the pool, as text and never as a second ring. The upgrade / top-up button's test id is now ai-usage-cta (it was ai-usage-cta-build / ai-usage-cta-dataChat)." — RIGHT; the removed template literal was ai-usage-cta-${key} over build / dataChat.
  • "New locale key console.ai.usage.breakdownTitle ("Used so far") in every locale pack." — RIGHT: ten packs in the diff (ar, de, en, es, fr, ja, ko, pt, ru, zh); the en value equals the component's defaultValue.

Locale copy: en breakdownTitle: 'Used so far' with a comment saying the rows reuse meterBuild / meterAsk and each is a share of the ONE pool, never a second budget — RIGHT against splitLabel and the producer's "fractions of the SAME binding-window cap". zh breakdownTitle: '已用额度构成' (the composition of the allowance used so far) — RIGHT as the heading over a split of one pool, with no second-budget wording. The other eight packs render the same heading — RIGHT.

PR body producer sentences: "breakdown is always an object whose members are number or null" and "every error path answers 200 with a neutral pool (fraction: null) and both members null" — RIGHT against buildAiUsageResponse and unknownResponse at cloud origin/main; the PR read the producer at 96eb092, and the file at 72857a1 declares the same shape.

Nothing in the diff or its prose is judged WRONG.

② Semver level

The changeset declares @object-ui/app-shell: patch and @object-ui/i18n: patch, and the diff touches exactly those two packages plus the changeset itself.

  • @object-ui/i18n: one additive key per pack — patch is RIGHT.
  • @object-ui/app-shell: a bug fix. The only type-level change (AiUsageResponse, new AiUsageBreakdown) is in hooks/useAiUsage.ts. The claim's Clause-②: no rests on the barrel facts it states — hooks/index.ts exports these, the package entry src/index.ts (the . export) re-exports none of them — and the PR body repeats them. Neither barrel is in the diff, and the entry file is outside this review's allowed inputs, so that premise is carried here by the head's gate verdicts: Changeset Bump Policy, Changeset Declaration, Changeset Claim Re-read, Changeset Fixed Group Check, Governed Surface Queue Guard and README Export Check are all success. On that premise no published accept set or public surface moves; patch is RIGHT.
  • The test-id rename and the new error on an unreadable answer are not public-surface changes.

The changeset's level matches what the diff publishes and matches Clause-②: no on the newest Claim: comment on the card (5864143926).

③ Boundary flags

The dev report (os-dev-report, card comment 5864927031) declares open_questions: []. Its deviations and the PR body's Acceptance notes, each:

  1. Split placement in the popover rather than a native title tooltip — ANSWERED. The card allows "a tooltip or a detail view"; the routing constraint asks only that it never be a second gauge, and it is not.
  2. No type rename; AiUsageBreakdown added in the hook file; both barrels untouched — ANSWERED (see ②); the claimed file surface is respected, barrels excluded.
  3. useAiUsage sets error on an unparsed answer; no UI reads it — ANSWERED; additive and consistent with contract-first.
  4. Strict breakdown (present but malformed is rejected and the ring hides), cited as a PM decision (option A), not a maintainer ruling. The decision text lives on the dev report and in the PR body, not in a separate PM comment. ANSWERED: it follows from the triage seat's routing constraint (no tolerant read) and the card's "the producer moves once, this consumer follows once"; the one case it costs, breakdown: null, is not something the producer sends. No maintainer decision is owed.
  5. Delivery order: the tenant image bundles objectui at cloud's .objectui-sha, so this reader ships beside a { pool, breakdown } runtime; the one gap is a hotfix branch that moves the pin without cloud#2144 (production's last tenant deploy, hotfix/v1.31.1-security, predates it). ESCALATED, and correctly not answered with a dual read; the remedy is the cloud-side .objectui-sha bump the claim says follows this landing, which is not this diff's to make.
  6. Process notes (verify-lock duration, the classifier outage, three origin/main merges with no overlap on the touched files) — ANSWERED; the PR is mergeable: clean against main 8522396.
  7. NOT MEASURED check:eager-locale-catalogues (needs apps/console/dist/eager-closure.json) — ESCALATED to CI; the head's build-dependent runs (Build & E2E, Bundle Analysis, Docs Route Eager Closure Check) are success, and the diff adds one row to catalogues already inside whichever closure they sit.
  8. NOT MEASURED browser run against a live cloud runtime — ESCALATED; the through-the-real-hook cases in AiUsageIndicator.test.tsx drive the wire payload through the real parser into the component for both the { pool } and the { meters } answers, and the wire shape is read off the producer source, not assumed.
  9. Ordering (Blocked-by: objectstack-ai/cloud#2133) — SATISFIED: closed completed on 2026-09-08 with cloud#2144 merged (unblock comment 5824206473); the triage seat's second condition (the pin) is item 5.

Check-runs on the head: 43 in all, 40 success, 3 skipped (the coverage matrix template row, Test (coverage), dependabot — conditional jobs, not failures), none failure.

Implemented-by: claude/issue-8524-ai-usage-single-pool
Reviewed-by: session_015AUunPkX7UTkCH9e7AdZo1

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 07:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 28, 2026
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 7c9145f Sep 28, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-8524-ai-usage-single-pool branch September 28, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants