Skip to content

fix(app-shell): AgentPreview's empty rail collection announces itself in words - #8527

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-8520-agentpreview-empty-collection-state
Sep 8, 2026
Merged

os-justin merged 1 commit into
mainfrom
claude/issue-8520-agentpreview-empty-collection-state

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8520

KeyVals's zero-rows branch in AgentPreview.tsx rendered a bare em dash in a plain div — no role, no label, no text alternative. It now renders EmptyDescription from @object-ui/components with the text "No values set.".

Which members, and why

Used: EmptyDescription, alone, with className="text-xs italic".
Not used: EmptyValue (ruled out by the card). Also not used: the Empty container.

Dropping the container is the one judgement objectui#8520 left open, and it is a departure from the ruling's stated guess, so here is the measurement it was asked to rest on rather than an assertion.

The measurement

Chromium at /opt/pw-browsers/chromium, the dev preview gallery (apps/console at ?only=agent), viewport 1440x900. Every candidate rendered in the same page load, inside the real AgentPreview side rail — rail box 240px, content column 215px — reading getBoundingClientRect and getComputedStyle off each:

candidate height font align border
A before: div.text-muted-foreground.italic holding the em dash 16px 12px start none
B shipped: EmptyDescription + text-xs italic 16px 12px start none
C EmptyDescription, family defaults 22.8px 14px start none
D Empty wrapping EmptyDescription — the ruling's prediction 118.8px 12px center dashed at 0px width
E the same, className="px-3 py-8" — this repo's own sidebar precedent 118.8px, identical to D 12px center dashed at 0px
F the file's own local Empty (see below) 16px 12px start none

D is 7.4x the row it replaces, centred, inside a 215px rail whose other blocks are 16px-per-row definition lists. It is a full-canvas panel and the rail is not a canvas. E shows the obvious escape hatch does not work: an unprefixed px-3 py-8 replaces the base p-6 but never touches md:p-12, so above 768px the padding comes straight back — filed as objectui#8525 along with the dashed border that renders at 0px width at every call site in the repo.

B is metrically identical to A on all four axes. The layout delta of this PR is zero; only the announcement changes. That is worth stating plainly because the ruling predicted the opposite ("the visual delta here is deliberate and larger than the six sites in objectui#8504") — measured, there is no visual delta at all, which is a better outcome than the one that was being braced for.

Why not the container at a smaller size

Empty can be beaten into 16px with gap-0 p-0 md:p-0 items-start text-left — measured, it works. It also neutralises six of the container's ten utilities and leaves rounded-lg border-dashed text-balance doing nothing. Fighting a component that hard is the component telling you it is the wrong member.

Why EmptyDescription standing alone

All 28 existing EmptyDescription call sites in this repo nest it inside Empty, so this is a new composition and reviewers should look at it. The case for it: it is a block-level text slot (data-slot="empty-description"), it carries the design-system hook so a future restyle reaches this site, and it states the condition in words — which is the whole of the defect. The case against is precedent, and it loses to a 7.4x height difference.

What the sibling census found

The ruling asked for it before choosing. Probing packages/app-shell/src/views/metadata-admin/previews/* with an element-agnostic grep for the family: zero of the 15 empty-collection states in that directory use it. All 15 hand-roll a muted sentence, across six type scales. Two of them — AgentPreview.tsx:221 and ToolPreview.tsx:296 — declare a local component named Empty, which is why this PR imports EmptyDescription and not Empty: the names collide in this file, with no compiler error waiting for whoever tries. Filed as objectui#8526.

The local Empty sits 166 lines above the defect and is used at line 136 for the same kind of state. The zero-rows branch was the file's own outlier, not a site that had no block-level empty state available.

Test

AgentPreview.emptyCollection-8520.test.tsx, six cases. It navigates by the rail block's title ("Planning", owned by RailBlock) and never by anything the empty state renders — so a caricature that erases the description fails an assertion instead of killing the harness.

Four ablation legs, each mutated on disk, proven by hash in both directions, restored by state:

leg result failure mode
revert to the bare em dash RED 4/6 assertion failures, harness alive
EmptyValue with className="italic" — the plausible wrong fix RED 5/6 assertion failures, harness alive
strip the "Planning" title from RailBlock — harness-kill RED 6/6 every case dies on "Unable to find an element with the text: Planning", textually distinct from the two above
Empty wrapping EmptyDescription — the ruling's guess GREEN 6/6 survivor, see below

The labelled discriminator was inert on first run and had to be repaired. The assertion written specifically to catch EmptyValue — "is not labelled No value" — sat green through a measured EmptyValue caricature, because KeyVals returns the empty state as the rail block's body element and Element.querySelectorAll never matches the element it is called on. The axis that actually caught it was the generic one: the state announces letters, not punctuation. Repaired with a self-inclusive selector, re-run, and it now fires; the miss is recorded in the test's own comments so it does not get re-introduced.

Declared scope: the surviving leg is not a bug in the pin. This test measures the accessibility contract — a readable, correctly-scoped, block-level announcement — and not visual weight. The container variant is a legitimate fix by that contract and is rejected on the browser measurement above, which is argument, not assertion. A future change that swaps in the container will pass this test; that is intended.

Checks run locally

  • pnpm exec vitest run packages/app-shell/src/views/metadata-admin/ — 239 files, 2487 passed, 1 skipped
  • pnpm --filter @object-ui/app-shell type-check — green, after pnpm --filter '@object-ui/app-shell^...' build. That project chains tsc -p tsconfig.test.json, whose include is src/**/*.test.tsx, so the new pin is compiled and not merely run.
  • pnpm exec eslint . — the full repo population, 4494 files, 0 errors. No narrowing, and no --no-inline-config (an objectstack convention that manufactures errors CI does not have here). no-irregular-whitespace is on at error level via the preset; note its skipStrings: true, so a separate scan of the diff for control bytes, zero-width and irregular whitespace was run and is clean.
  • pnpm check:control-bytes — green. node scripts/check-changeset-presence.mjs — green, 1 changeset for 1 changed published source file.

Reachability

No manifest edge. @object-ui/components is already "workspace:*" under dependencies in packages/app-shell/package.json, and this file already imported cn from it.

i18n

The string is a literal, matching this file — AgentPreview.tsx has no t() call anywhere and states all of its copy in English ("No system prompt set yet.", "Defaults in use."). Introducing one key here would be the inconsistency, not the fix.


🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

… in words

`KeyVals`'s zero-rows branch rendered a bare em dash in a plain `<div>` with
no role, no label and no text alternative, so a screen-reader user reaching a
rail block whose keys all resolved to `undefined` heard a naked punctuation
mark. It now renders `EmptyDescription` from `@object-ui/components` with the
text "No values set.".

This is an empty COLLECTION, not an empty field value, so `EmptyValue` is the
wrong member of that family: its docblock scopes it to a missing cell/field
value and its `aria-label` resolves `detail.noValue` — "No value" — a false
statement about a collection that simply has no members. The container `Empty`
is not used either: measured in the browser at this rail's real 215px content
width it is 118.8px tall against the 16px row it replaces, and its centred
panel is built for a full canvas. `EmptyDescription` at `text-xs italic` is
metrically identical to the row it replaces (16px, 12px, start-aligned,
italic), so the layout delta is zero and only the announcement changes.

The pin navigates by the rail block's own title, never by anything the empty
state renders, so a caricature that erases the description fails an assertion
instead of killing the harness.

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

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

❌ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3474.1 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-Js8zmsRe.js —
Status FAIL —

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.

Which half objected:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ❌ over its ceiling
Ceiling sensitivity (headroom) ✅ pass
Ceiling freshness (checkout vs. base branch) ✅ pass

📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
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) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
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.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
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) 26.08KB 7.56KB
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) 498.87KB 114.10KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 191.36KB 53.16KB
fields (index.js) 243.24KB 61.42KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
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) 5.12KB 1.74KB
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) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.73KB 63.97KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.30KB 56.63KB
plugin-kanban (index.js) 55.40KB 15.71KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
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.66KB 3.50KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
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.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
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 (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 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

@os-justin
os-justin marked this pull request as ready for review September 8, 2026 06:18
@os-justin
os-justin enabled auto-merge September 8, 2026 06:18
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 5eff8b1 Sep 8, 2026
33 of 34 checks passed
@os-justin
os-justin deleted the claude/issue-8520-agentpreview-empty-collection-state branch September 8, 2026 07:03
os-justin pushed a commit that referenced this pull request Sep 8, 2026
…pty-collection sentences

Sixteen sites across twelve files under `metadata-admin/previews/` each
hand-rolled the same empty-collection state — a muted italic sentence in a
plain `div` — and two of them (`AgentPreview`, `ToolPreview`) declared a
file-local component literally named `Empty` that shadowed the shared family
in the one place a maintainer would reach for it. They now render
`EmptyDescription` from `@object-ui/components`, used alone outside any
`Empty` container, the shape PR #8527 measured at 16px / 12px / start-aligned
/ italic against the row it replaced.

Every site keeps its measured size class, so the per-site layout delta is
zero: `text-xs` where the row sat in (or inherited from) a text-xs rail,
`text-[10px]` for the run row's step log, `text-[11px]` for the translation
category card, and `text-sm` for the screen body's sentence, which stands in
for a description that renders at text-sm. Bare `italic` would have kept the
family's `text-sm/relaxed`, so each size is spelled out.

Not migrated, each for its own reason: the inline `span` carriers
("none", "not configured"), the amber warning-toned sentences, the dashed
drop-zone placeholders, the palette search empties, the `PreviewEmptyState` /
`PreviewMessage` full-canvas states, and the unknown-rule-type fallback whose
chrome is the CEL block's own box.

The pin navigates every site by a title its container owns, never by the
empty state, so a caricature that renders the description unconditionally
fails the populated assertions instead of killing the harness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 9, 2026
…pty-collection sentences (objectstack-ai#8569)

* fix(app-shell): draw the shared EmptyDescription for the previews' empty-collection sentences

Sixteen sites across twelve files under `metadata-admin/previews/` each
hand-rolled the same empty-collection state — a muted italic sentence in a
plain `div` — and two of them (`AgentPreview`, `ToolPreview`) declared a
file-local component literally named `Empty` that shadowed the shared family
in the one place a maintainer would reach for it. They now render
`EmptyDescription` from `@object-ui/components`, used alone outside any
`Empty` container, the shape PR objectstack-ai#8527 measured at 16px / 12px / start-aligned
/ italic against the row it replaced.

Every site keeps its measured size class, so the per-site layout delta is
zero: `text-xs` where the row sat in (or inherited from) a text-xs rail,
`text-[10px]` for the run row's step log, `text-[11px]` for the translation
category card, and `text-sm` for the screen body's sentence, which stands in
for a description that renders at text-sm. Bare `italic` would have kept the
family's `text-sm/relaxed`, so each size is spelled out.

Not migrated, each for its own reason: the inline `span` carriers
("none", "not configured"), the amber warning-toned sentences, the dashed
drop-zone placeholders, the palette search empties, the `PreviewEmptyState` /
`PreviewMessage` full-canvas states, and the unknown-rule-type fallback whose
chrome is the CEL block's own box.

The pin navigates every site by a title its container owns, never by the
empty state, so a caricature that renders the description unconditionally
fails the populated assertions instead of killing the harness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S

* test(app-shell): navigate the 8526 pin by the titled container, not by a child position

The caricature (EmptyDescription rendered unconditionally, populated
collections included) was run against the first version of this pin: eight of
the seventeen populated cases died on their marker lookup — "Unable to find an
element" — before the refusing `toBeNull` assertion ever ran, because the
harness took `children[1]` as the body slot and the caricature's inserted
sibling shifted the rows one slot over. Every navigator now returns the
container that owns the title, and both assertions are descendant queries on
it; the empty state can never be that container, because the container is
what holds the title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A seventh em-dash placeholder with no accessible name, in a <div> — invisible to every span-shaped census objectui#8491/#8504 ran

2 participants