Skip to content

fix(app-shell): bind the host discussion fallback to the feed pipeline - #9011

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-8983-host-discussion-fallback-feed-pipeline
Sep 10, 2026
Merged

os-steve merged 1 commit into
mainfrom
claude/issue-8983-host-discussion-fallback-feed-pipeline

Conversation

@claude

@claude claude Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #8983

One pipeline, not two. The host discussion fallback now mounts the same
renderer the authored / synthesized record:discussion / record:chatter
block does, instead of reaching past it to RecordChatterPanel with raw rows.

Drafted by an agent seat in Claude Code session session_01MPaVWWMuWeT5LgB1qoXjVB
(the id is written here as prose because a footer link does not survive an edit
to this body).

Why the fallback exists — measured, not assumed

The triage ruling required establishing this before binding anything, since a
host context where the pipeline genuinely cannot run would call for saying so
visibly rather than binding.

It can run. Measured against origin/main at e8b7b0785:

  • The gate is showAutoDiscussion = !disableDiscussion && !hasDiscussion && feedsEnabled
    in packages/app-shell/src/views/RecordDetailView.tsx. So the fallback fires
    for exactly one population: an authored page that omits a discussion
    block. A synthesized default page always emits record:discussion
    (buildDefaultPageSchema.ts, the hideDiscussion branch), so it never
    reaches the fallback. The tree says the same in as many words two lines above
    the gate: "the legacy bottom-of-page append below stays only as the fallback
    for authored pages that don't slot the panel themselves."
  • It is not a different host context. The fallback JSX sits inside the very
    DiscussionContextProvider this same view opens (provider opens at the
    items={feedItems} mount, closes 172 lines later; the fallback is between
    them), itself inside RecordContextProvider. RecordChatterRenderer needs
    exactly those two contexts and nothing else.
  • The six values the fallback passed by hand — items, loading, and the three
    mutation handlers, plus mention suggestions — are the same six the provider
    above it is already given, from the same closure variables.

So the honest answer is the binding one, and the "say so visibly" branch of the
ruling does not apply.

Dependency measurement

Binding adds no package dependency edge:

  • @object-ui/plugin-detail is already declared by @object-ui/app-shell in
    both peerDependencies (workspace:^) and devDependencies (workspace:*).
  • RecordDetailView.tsx already imports from that specifier — the edit swaps
    one named import (RecordChatterPanel) for another (RecordChatterRenderer)
    in the same existing import statement. No new specifier, no new edge.
  • RecordChatterRenderer is exported from the package barrel
    (packages/plugin-detail/src/index.tsx), not a deep path.

How it binds

packages/app-shell/src/views/RecordDetailView.tsx, the showAutoDiscussion
branch, is now a single RecordChatterRenderer with className="mt-6" and no
schema
. That is the component ComponentRegistry resolves both
record:discussion and record:chatter to, so it is literally the same
pipeline rather than a second copy kept in agreement by hand.

  • With no schema, the renderer's own defaults are the three affordances this
    branch used to hard-code (position: 'bottom', collapsible: false,
    reactions / threading / comment input) plus the protocol's feed defaults.
  • The renderer takes no items prop; it reads the feed off the
    DiscussionContext already in scope. DOM is unchanged: the renderer emits its
    own div carrying className, so the old wrapper div is not lost.
  • Routing a synthesized node through SchemaRenderer was the alternative
    considered and not taken: it would add hidden / disabled expression
    evaluation over a node nobody authored, to arrive at the component mounted
    here anyway.

Two comments that this change makes false were rewritten in the same edit — the
record-chatter.tsx note that recorded the divergence as live, and the
EMPTY_FEED note in RecordDetailView.tsx that named RecordChatterPanel as a
direct prop consumer.

The equivalence assertions, and the controls that make them readings

New pin: packages/app-shell/src/views/RecordDetailView.discussionFallbackPipeline-8983.test.tsx.
It drives the real RecordDetailView twice on the same feed — once with an
authored page that omits the block (fallback), once with no authored page at all
(synthesized default, so the block) — and asserts the rendered row lists are
equal and equal to a stated value.

"The two agree" is satisfiable by a harness in which they could never have
disagreed, so three legs hold it up:

leg what it does
PRE-FIX CONTROL PreFixFallbackShape is the fallback's JSX as written before this card — same panel, same hard-coded affordances, raw items, no hasMore / onLoadMore. Rendered on the same feed, derived from the same source rows through the exported activityRowToFeedItem, and asserted to produce a different row set.
AGREEMENT FIXTURE the shared row set is asserted by value, and the value is the pipeline's answer (completed row hidden; newest twenty of twenty-five) rather than the raw feed. Both surfaces rendering nothing, or everything, is red.
NEUTRAL FIXTURE the card's own control row — three comments, no completed activity, under the page window — on which all three shapes agree. Without it the two cases above could be reporting a harness that can only produce disagreement.

The three fixtures are the card's own probe table, re-run through the real view:

feed host fallback authored / synthesized block PRE-FIX shape (control)
3 comments + 1 completed activity 3 rows, Load more x0 3 rows, Load more x0 4 rows incl. the completed one, Load more x0
25 comments 20 rows (06..25), Load more x1 20 rows (06..25), Load more x1 25 rows, Load more x0
CONTROL — 3 comments, no completed activity 3 rows 3 rows 3 rows

A fourth case clicks "Load more" on the fallback and asserts the window grows to
all 25 rows and the button retires — hasMore without onLoadMore renders a
button that does nothing, which satisfies any assertion that only counts it.

Ablation

Two arms, both run from the committed tree so the restore leg has something to
restore to, and both proving the edit reached disk before any result was read.

MUTATION arm — the fallback branch and its import reverted to the pre-fix
raw shape (RecordChatterPanel with items={feedItems}). On-disk proof taken
before the run, anchored on the exact text each way: RecordChatterRenderer
mount sites went 1 to 0, RecordChatterPanel mount sites 0 to 1, and the file's
blob hash moved from f4fb0b7 to 6e18560 (a hash comparison, because an
editing tool exits 0 on zero matches). Result: 3 of 4 failed, and they
failed for the stated reason rather than by crashing —

AssertionError: expected [ 'Comment 01', 'Comment 02', ...(2) ] to deeply equal [ ... ]
+   "Completed task row"
+   "Comment 01"  "Comment 02"  "Comment 03"  "Comment 04"  "Comment 05"
    "Comment 06"  ...

i.e. exactly the divergence the card measured: the completed activity back on
screen, and the page window gone. The fourth case passed — and it is the
NEUTRAL FIXTURE, which is the predicted arm, not a miss: on a feed with nothing
to filter and nothing to page, binding the pipeline changes nothing, so that leg
must stay green under the mutation or it was never a control.

RESTORE arm — git checkout HEAD -- packages/app-shell/src/views/RecordDetailView.tsx,
verified by git diff HEAD being empty and the blob hash back to f4fb0b7
(not by the restore command's exit code, and not by the trap having fired).
Re-run: 4 of 4 pass.

Gates

gate command exit reading
type-check turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/plugin-detail 0 31 tasks successful; app-shell's task is tsc --noEmit && tsc -p tsconfig.test.json, so the new pin is type-checked too
lint turbo run lint --filter=@object-ui/app-shell --filter=@object-ui/plugin-detail 0 3 tasks successful, 0 errors (2967 pre-existing warnings, unchanged in kind)
affected suite — plugin-detail pnpm exec vitest run packages/plugin-detail/ 0 162 files, 1517 tests passed
affected suite — app-shell pnpm exec vitest run packages/app-shell/ 0 676 files, 6532 passed / 1 skipped. 676 is the whole package (find packages/app-shell -name "*.test.ts*" counts 676), so the filter did not silently narrow
new pin alone pnpm exec vitest run ...discussionFallbackPipeline-8983.test.tsx 0 4 of 4 passed
check:control-bytes as named 0
check:changeset-presence node scripts/check-changeset-presence.mjs 0 2 published source files changed, 1 changeset declared
check:governed-queue-guard --test the four changed paths 0 NOT GOVERNED — 4 paths checked against 5 governed surfaces, none matched
check:doc-snippets as named 0 after the scoped build it names; 638 of 638 blocks judged
check:doc-types as named 0 188 doc files, 1105 blocks
check:doc-examples as named 0 after the scoped build; 124 blocks, 89 ledger-declared failures exactly as declared
check:new-line-citations as named 0
check:vi-mock-specifiers / -inherit / -override-shape as named 0 / 0 / 0 run because the new pin uses vi.mock
check:phantom-deps / check:unused-deps / check:unreferenced-sources as named 0 / 0 / 0
check:eager-closure as named 2 — PREREQUISITE NOT MET not in this card's gate family; it needs an apps/console build to have a gauge at all, and it is a CI job. Reasoned unaffected: both RecordChatterPanel and RecordChatterRenderer are re-exported from the SAME already-eagerly-imported @object-ui/plugin-detail barrel, so no module enters or leaves the closure

All heavy runs went through the container's shared verify lock; the wall-clock
figures in those logs are shared-box readings, so only the verdicts are quoted.

Both doc gates first returned exit 2 — PREREQUISITE NOT MET (packages
unbuilt), which is not a pass; the scoped build each names was run and they were
re-run to the exit codes above.

Assumptions from the dispatch, checked

  • "buildDefaultPageSchema.ts:880-883 emits record:discussion on every
    synthesized default page" — holds, and the anchors happen to be exact at
    this base.
    Re-derived by symbol: buildDefaultDiscussion is defined at
    :882 and called at :1031, guarded only by an authored slots.discussion
    override or the explicit hideDiscussion option. Same for the triage's
    RecordDetailView anchors (:2182, :2549, :1551) — all three landed on
    the named construct.
  • "the feed pipeline is reachable without a new package dependency" — holds;
    measurement above. Not a manual-floor item.
  • "there are exactly TWO chatter surfaces" — holds. A repo-wide sweep for
    RecordChatterPanel mount sites outside tests finds exactly two, plus the
    component's own definition. record:activity is a different block, and the
    designer palette entry in metadata-admin/previews/block-types.ts is a
    catalogue row, not a mount.
  • Serial adjacency re-derived against currently-open PRs rather than cited from
    the dispatch — see the report; no overlap with the four PRs named there.

Not in scope

objectui#8934's own ruling is untouched; this is its residue. objectui#8968
(filterMode / enableMentions still unread on the chatter path) is unaffected
either way — it is a gap in the shared pipeline, so binding the fallback to that
pipeline neither widens nor narrows it.

Acceptance notes

Seen while working here, deliberately NOT filed and NOT fixed in this PR — none
is a reproducible defect, a broken declared contract, or a trap that makes an
author write metadata the runtime drops:

  • RecordChatterPanel now has no mount site anywhere in this repository outside
    RecordChatterRenderer and this PR's own pre-fix control. It remains a
    published export of @object-ui/plugin-detail and consumers mount it, so this
    is not dead code — recorded only so the single remaining internal mount does
    not read as an accident to the next person here.
  • AGENTS.md's testing section quotes app-shell as having "281" test files
    while illustrating a historical trap. Measured today: 676. Nothing reads that
    number mechanically, it sits inside an anecdote rather than a judged
    population, and AGENTS.md is a governed surface where a drive-by edit costs
    a human merge. No taker identified; noted rather than filed for that reason.
  • The dispatch's serial-adjacency snapshot named four open PRs (objectui#8920,
    #8996, #8998, #8999). Re-derived live: eight PRs are open and none of
    them is any of those four. Adjacency was therefore re-derived rather than
    cited, and no open PR touches either file this PR edits.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB


Generated by Claude Code


Generated by Claude Code

A record page shows its discussion through one of two surfaces: the
authored/synthesized `record:discussion` / `record:chatter` block, which
renders through `RecordChatterRenderer`, or the host's bottom auto-append
for authored pages that omit the block. Since objectui#8934 the renderer
runs `applyFeedConfig`; the fallback reached past it to
`RecordChatterPanel` with raw rows, so the two rendered different feeds
from the same rows — a completed activity showed on one and not the
other, and a feed past twenty rows rendered whole on one and paged on the
other.

Bind, do not copy: the fallback now mounts `RecordChatterRenderer` with
no authored schema, so it reads the feed off the `DiscussionContext` the
view already provides and runs the one pipeline. No new package
dependency — `@object-ui/plugin-detail` is already app-shell's peer
dependency and already the source of this file's detail imports.

The new pin asserts the two surfaces render the same rows, with the
pre-fix fallback shape rendered on the same feed as the control that
proves they used to differ, and a neutral fixture on which all three
agree.

Part of #8983

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3490.1 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-Duht4T0W.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.69KB 6.21KB
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) 500.20KB 114.67KB
core (index.js) 7.95KB 3.19KB
create-plugin (index.js) 28.21KB 9.54KB
data-objectstack (index.js) 207.32KB 57.37KB
fields (index.js) 247.01KB 62.29KB
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) 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.39KB 3.10KB
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) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.63KB 19.99KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 130.98KB 34.54KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 253.71KB 65.78KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 210.87KB 57.30KB
plugin-kanban (index.js) 46.03KB 14.30KB
plugin-list (index.js) 112.52KB 27.64KB
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.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
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) 83.34KB 27.61KB
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.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
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) 14.82KB 4.99KB
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 (strict-authoring-face.js) 14.27KB 5.47KB
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-steve
os-steve marked this pull request as ready for review September 10, 2026 17:59
@os-steve
os-steve added this pull request to the merge queue Sep 10, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 10, 2026
@os-steve
os-steve added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 0601af1 Sep 10, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-8983-host-discussion-fallback-feed-pipeline branch September 10, 2026 18:52
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.

finding(app-shell): the host discussion fallback does not run the feed pipeline the authored record:chatter / record:discussion block now runs

2 participants