You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Supersedes #2526, which is closed in favour of this. Every commit from that PR is carried here with its original authorship (@o-p-o-p-o), rebased onto current master; nothing of it is dropped. See From #2526 below for the two things that changed in the move.
Two pieces of work, in order: the claim page's hero becomes the Explore claim card at page scale and topics move to their own tab (#2526), and that tab then becomes an explore feed of topic cards (this PR's own three commits).
Layout: the Explore claim card's two zones. Left: the claim, its description and the Agree/Disagree pills (capped at 360px), with the debate offer under them. Right: Explore's own 220px verdict column behind a full-height rule.
Alignment: the verdict starts on the title's row, so the share lines up with the claim's first line.
Narrow widths: under a 520px container (claim-card-narrow) it stacks as the card does — verdict below the pills, no rule. The hero is its own @container, so the switch follows the hero's width, not the window's. That is what matters in the side panel.
Unanswered claims: no verdict column, full width, as on Explore.
Chips: type and tag chips are gone. Controversial is a red chip above the title.
Topics: move to a tab. A hero topics row is built but switched off (SHOW_HERO_TOPICS = false).
ClaimVerdict is deleted — the claim page was its only caller. ClaimSideResponders gains a "6 people agree" title, profile-picture-first ordering and browse-sidebar hover, which apply wherever that popover opens.
The Topics tab
That tab landed as the chip section, uncapped. A chip carries a name and nothing else, so a tab of them is a list with no way to tell which entry is worth opening — and it is the list the hero's "See all" deliberately sends people to.
Topics now render as an explore feed, ordered largest first by exactly the numbers the cards show.
Order: debates + claims + news stories, descending, and only when every batch answered. Ties break on claims, then on name, so the order is stable rather than merely sorted.
Nothing paints until the counts are in. The rows and the counts are two requests; painting the first would show the claim's own topic order and resequence it a beat later — the trade useTopicLinkedEntities already makes.
A failed count still lists the topics, in the claim's order, with no metadata line. A count that could not be read is not a count of zero. That includes a partial failure — the counts batch, so one batch can fail while another answers, and ordering on half the numbers is neither the claim's order nor largest-first. The counts that did arrive still reach their own cards.
A topic related twice, in two spaces, is one card.
The topic card
TopicExploreFeedCard is the generic explore card — the same frame, meta row, thumbnail well, title, two-line description and vote/comment row, now shared as components rather than copied. Everything new is one line between the description and the actions:
3 debates · 117 claims · 2 news stories
Ordered debates, then claims, then news stories — rarest and most specific to least. The list's own ordering is separate: it sorts on the total and breaks a tie on claims.
Kinds a topic has none of are left out rather than printed as zeros. A topic with none of the three names them rather than claiming to be empty ("No debates, claims or news stories yet") — it also carries episodes, tweets and posts this card never counts.
The noun agrees with the count, and thousands are separated.
Deliberately not wired into ExploreFeedCard's type dispatch. The counts are a second request and the main feed pre-mounts cards thousands of pixels below the fold, so a Topic drawn anywhere else keeps the card it has today.
The counts
useTopicConnectionCounts asks entitiesConnection, not relationsConnection.
relationsConnection is faster and is what the topic page's composition strip uses — but it counts links, not things. The same claim carrying Topics in two spaces is two relations: on AI governance that is 552 relations against 505 claims, and 209 against 195 news stories. A card saying 552 beside a topic page saying 505 is a number nobody can reconcile, and here the number also orders the list.
Debates were never a Topics relation at all — a Debate carries Claims and never Topics — so the link is two hops, debate → claim → topic. That is also exactly what "debates connected to the attached claims" means.
Counts are unscoped to spaces, unlike the composition strip: a topic gathers across the whole graph, and narrowing to a viewer's allowlist would make a topic's size depend on who is reading and would reorder the tab the moment that allowlist resolved.
Batched at ten topics per request, and the parsed document is cached by batch size since the ids are variables. Across 3,000 claims sampled from testnet the largest carries 10 topics (p95 of 3, p99 of 4) and not one needs a second batch, so this is a single request in practice; the batching is what stops a pathological claim becoming one unbounded query, and what keeps a batch that fails from costing every other topic its numbers.
Reuse
The card and the tab are assembled from what already exists rather than reproduced:
explore-card-chrome.tsx (new) holds the card frame, the default body and the actions row, extracted from explore-feed-card. The body takes an optional meta slot — the whole of what a topic card adds. Its own module, not an export from the dispatcher, for the reason ExploreCardTitle is one: importing back from explore-feed-card would close a cycle the moment it ever draws a topic.
META_SEGMENT_CLASS is exported from explore-meta-row and imported, rather than the metadata line spelling the type out again. That file's own docstring records what happened last time a card reproduced this row by eye.
PersonRecordFeed draws the feed, as the Sources tab already does for the same shape of question (a bounded id list hydrated into explore cards). It gained an optional renderCard; the space lookup, item projection and the loading/empty/error/partial-failure states are unchanged and unduplicated. The key stays inside the feed.
useClaimExploreRows hydrates the topic ids — the same hook Related claims, Debates and Sources use.
The counts batch at ten the way useClaimExploreRows and fetchExploreRowsByIds batch ids.
ExploreFeedCommentLink moves to its own module so the shared actions row can draw it without a cycle. No behaviour change.
The Activity-header conflict is resolved in favour of what merged.#2526 took the Activity header from #2514's branch "as-is, so whichever of the two PRs merges second will have nothing to do in these files". #2514 has since merged, and the copy had drifted from it — flex where master now has flex-wrap, an arrow guard on navigation where master uses selected.rows.length, plus a scroll fade #2514 dropped before merging. profile-activity-section.tsx and its test are now byte-identical to master, so that work is no longer duplicated here at all.
Three review findings in the hero and the debate card are fixed.
The hero re-wrapped its own title on every load.hasVerdict cannot be true until the response counts answer, and the grid template was derived from it, so every claim painted one-column and reflowed to 1fr 220px when the column appeared. The template now follows a separate question, keyed on hasCounts rather than isLoading: unknown reserves the second track, only a measured zero releases it. A counts query that exhausts its retries reports zero with nothing loading — the shape of an unanswered claim, which is exactly what it is not — so keying off isLoading would have re-wrapped the title on failure, and again if a later refetch succeeded. The column's contents still wait for real numbers.
An empty grid row sat above the claim. Row 1 belongs to the controversial/topics strip, which draws for almost nothing since SHOW_HERO_TOPICS is off; with it empty the rows still started at 2, so claim-card-narrow:gap-y-4 left 16px above the claim belonging to a row nothing occupied. Measured 16px → 0px at 420px.
A full-width debate stopped fitting the screen.fullWidth dropped the max-wand the viewport-height budget, but it only ever meant "don't cap me at 560px, this column is already the reading width". On the 840px column that made a debate ~1140px tall, so on a 900px viewport the second debater and the interaction bar could not be seen together. It now raises the ceiling to the container rather than removing it:
viewport
before
after
1440×1300
800px
800px
1440×900
800px
569px
1440×700
800px
403px
Checks
tsc --noEmit clean. ESLint and Prettier clean on every changed file.
partials, core/claims, core/topics, core/explore, core/debates/browse: 174 files, 1,622 tests, all passing. Every fix for a review finding has a test that was checked against the old behaviour — reverting the fix fails the test.
Checked in a browser against testnet at 1280px, 420px, and in the entity side panel. On a claim with three topics the tab orders them AI governance (706) → AI safety (122) → Model safety evaluation (12), and every count matches the API asked directly. The claim title measures 73px at first paint and 73px settled; the dead space above it at 420px is gone.
The fullWidth budget is pinned by tests: the default 560px clamp, the container-bounded value under fullWidth, and fullWidthDebates threading through ClaimRecordTab.
Not verified: the full-width debate card's video layout. A logged-out card falls back to the generic one, so the column arithmetic above is measured in a browser and the declaration is pinned by tests, but the two-video layout inside that column is not.
- The title (entityTitle), type/tag chips and the verdict form one block
above the tabs, with Agree/Disagree inside the verdict and no card
border or padding. 48px between the chips, the verdict and the tabs.
- The percentage uses mainPage in the text colour; 12px above the bar,
8px below it, and no swatch dots on the sides row.
- Controversial is a red chip beside the type and tag chips, in the
chips' own size and type.
- The responder list is titled "6 people agree" above the rows, puts
people with a profile picture first, and highlights rows on hover like
the browse sidebar.
- The Debates tab lets a debate fill the content column.
- Brings in the Activity section header from #2514 (pills, View all,
header arrows, 12px compact player corners).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… add a Topics tab
- The hero is the Explore claim card at page scale: the claim and its
Agree/Disagree pills on the left, Explore's own 220px verdict column on
the right behind a full-height rule, starting level with the title.
Under a 520px container it stacks, as the card does. A claim nobody has
answered takes the full width.
- ClaimVerdictColumn is exported from the Explore card for this; the
page's own ClaimVerdict is removed, since nothing else used it.
- The title and the verdict's type go back to their original sizes; the
type and tag chips are gone, and Controversial sits above the title.
- Topics move to a Topics tab (/topics), listed uncapped. The hero's
capped topic row is built but off (SHOW_HERO_TOPICS), and the Overview
no longer repeats them.
- The page column is 840px, for 800px of content.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… card that says how big it is
The Topics tab landed as a row of chips. That is the right shape for a header strip and the
wrong one for a tab: a chip carries a name and nothing else, so a tab of them is a list with no
way to tell which entry is worth opening — and it is a list the hero's "See all" deliberately
sends people to.
Topics now render as explore cards, ordered by how much is attached to them.
The card is the generic explore card down to the class — same meta row, same 60px thumbnail
well, same title and two-line description, same vote/comment row — plus one line of metadata
between the description and the actions: claims, news stories, and the debates argued on those
claims. Kinds a topic has none of are left out rather than printed as zeros; a topic with
nothing attached says so once. A count that could not be read draws no line at all, because a
failed count is not a count of zero.
The counts are asked of `entitiesConnection` rather than of `relationsConnection`, which is
faster and is what the topic page's composition strip uses. It counts links, not things: the
same claim carrying `Topics` in two spaces is two relations, and on `AI governance` that is 552
relations against 505 claims. The number here also orders the list, so it has to be a count of
things. Debates are two hops — debate to claim, claim to topic — which is what "debates
connected to the attached claims" means.
One request for the whole tab: each topic contributes three aliased counts, and the parsed
document is cached by topic count since the ids are variables. Five topics measured ~0.75s
against testnet.
Nothing paints until both the rows and the counts are in. They are two requests, and painting
the first would show the claim's own topic order and resequence it under the reader a beat
later — the trade `useTopicLinkedEntities` already makes.
Also here:
- `ExploreFeedCommentLink` moves to its own module so the topic card can draw the same link
without closing an import cycle through `explore-feed-card`.
- `RelationChipSection`'s `cap` prop is reverted. It was added for this tab to pass `Infinity`,
and this tab no longer draws chips; Subtopics, its only remaining caller, uses the default.
- The topic card is deliberately *not* wired into `ExploreFeedCard`'s type dispatch. The counts
are a second request and the main feed pre-mounts cards thousands of pixels below the fold, so
a Topic drawn anywhere else keeps the card it has today.
Debates, then claims, then news stories — rarest and most specific to least.
A topic with debates on it is the interesting case, and the debate count is the one that most
often tells two topics apart; trailing it meant it was also the segment most likely to be
pushed onto a second line on a narrow card.
The ordering of the list itself is unchanged: it still sorts on the total, and still breaks a
tie on claims.
…and bound the counts request
Review follow-up. Three things the first pass got wrong on reuse, and two on robustness.
**The topic card was a copy of the generic body.** Twenty-five lines of thumbnail well, title
and description, reproduced class for class from `explore-feed-card`'s `DefaultCardBody` — the
third copy of that layout in this directory, and the first two drifted (the claim card's copy of
the meta row lost `font-normal` on three segments and ran eight pixels taller than the cards
either side of it, with nothing in either diff to show it).
`explore-card-chrome.tsx` now holds the frame, the body and the actions row. The body takes an
optional `meta` slot, which is the whole of what a topic card adds. Its own module rather than an
export from `explore-feed-card`, for the reason `ExploreCardTitle` is: reaching back into the
dispatcher would close a cycle the moment it ever draws a topic.
**The metadata line was spelling out the meta row's type.** `SEGMENT_CLASS` is now exported as
`META_SEGMENT_CLASS` and imported, which is exactly the drift the file's own docstring warns
about.
**The tab was re-implementing the feed shell.** `PersonRecordFeed` already does the space lookup,
the item projection and the loading, empty, error and partial-failure states, and the Sources tab
already uses it for the same shape of question — a bounded id list hydrated into explore cards.
It now takes an optional `renderCard`, and the Topics tab passes the topic card. The key stays
inside the feed so a caller cannot forget it.
**The counts request was unbounded.** Every topic added three aliased counts to one query, one of
them a two-hop nested filter, and a failure lost every topic's numbers. Batched at ten, the way
`useClaimExploreRows` and `fetchExploreRowsByIds` batch ids. Claims measured on testnet carry at
most 7 topics and three at the 95th percentile, so this stays one request in practice; what it
buys is that a pathological claim cannot become one huge query, and a batch that fails costs only
its own topics their metadata line.
**`countsPending` was unreachable.** The tab gates on the counts before it renders anything, so
the height-reserving skeleton the prop described could never draw. Removed rather than wired up.
No visual change: checked against testnet that Explore's News story card — the one that draws the
extracted body — and the Topics tab are both unchanged.
…ting the screen
Three findings from the review of this branch, all in the claim hero and the debate card it
brought in.
**The hero re-wrapped its own title on every load.** `hasVerdict` cannot be true until the
response counts answer, and the grid template was derived from it, so every claim painted
one-column and then reflowed to `1fr 220px` when the column appeared — a layout shift at the very
top of the page that the old below-header verdict never had. The template now follows a separate
question: keep both tracks while the counts are out, and give the second one back only on a
settled zero. An answered claim is the common case here, so the unknown state reserves the column.
The column's *contents* still wait for real numbers.
**An empty grid row sat above the claim.** The hero pins its parts to explicit rows so the verdict
can start on the title's, and row 1 belongs to the controversial/topics strip — which draws for
almost nothing, since `SHOW_HERO_TOPICS` is off. With it empty the rows still started at 2, so
`claim-card-narrow:gap-y-4` left 16px above the claim belonging to a row nothing occupied. Visible
in the side panel and at phone widths, where that gap is set. The rows now move up when there is
nothing to draw there. Measured 16px → 0px at 420px.
**A full-width debate stopped fitting the screen.** `fullWidth` dropped the `max-w` *and* the
viewport-height budget, but it only ever meant "don't cap me at the card's 560px, this column is
already the reading width". On the claim page's 840px column that made a debate about 1140px tall
— two `aspect-480/289` tiles at ~800px — so on a 900px viewport the second debater and the
interaction bar could not be seen together, which is the one thing the budget exists to guarantee.
`fullWidth` now raises the ceiling to the container rather than removing it: 800px at a 1300px
viewport, 569px at 900px, 403px at 700px, against a flat 800px before.
Checked in a browser against testnet: the claim title measures 73px at first paint and 73px
settled, and the dead space above it at 420px is gone. The column arithmetic is measured rather
than reasoned about — a logged-out debate card falls back to the generic one, so the video layout
itself is still unverified.
…s, and don't order on a partial count
Three findings from Copilot's review, all confirmed against the code.
**An empty navigation landmark.** `ClaimTopicsRow` was always a `<nav aria-label="Topics">`, but
with `SHOW_HERO_TOPICS` off the only thing that puts it on the page is a claim being
controversial — and that row holds one status chip and no links at all. Anyone moving through the
page by landmark got an empty region named after something it does not contain. It is a plain
`div` unless there are topic links to navigate. This is the only landmark in the PR that can be
empty: the hero's other one, `aria-label="Your position"`, always holds the pills.
**A partial count was ordered as if it were complete.** The counts arrive in batches, so one can
fail while another succeeds — which leaves a map covering some topics and not others. Sorting on
that ranked every unmeasured topic as zero and then alphabetised it: neither the claim's order nor
largest-first, with nothing on screen to say which topics had actually been measured. Any failure
now falls back to the claim's order, as a total failure already did. The counts that did arrive
still reach their own cards, so nothing measured is thrown away.
Only the one instance in this PR. `decodeTopicConnectionCounts` coalesces a missing bucket to
zero, which reads like the same mistake but is not reachable: `graphql-request` throws on a
response carrying `errors`, so a partial payload never gets to the decoder.
**An empty state that claimed more than it measured.** "Nothing attached yet" was printed when
debates, claims and news stories were all zero — but a topic also carries episodes, tweets, posts
and a long tail besides, six kinds and a remainder on the topic page's own composition strip. It
now names the three it counts.
Each fix has a test that was checked against the old behaviour: reverting any one of them fails
its test.
Not done, and answered on the threads: the suggestion to query an all-attachments total for that
empty state. It would add a fourth count to every topic in every request to fix a line that is
close to unreachable here — a claim's Topics tab lists topics the claim itself is attached to, so
the claims bucket is at least one for every row on it.
A terminal counts failure has isLoading === false and hasCounts === false, so this drops the reserved track exactly as if the server had reported zero responses. The title then widens/reflows after the retries finish, despite the stated rule that only a settled zero gives the column back. Keep the track whenever the counts are unanswered; remove it only when hasCounts is true and total is zero.
No test exercises the new fullWidth branch: the existing debate-card suite never passes fullWidth, and the claim-record test does not assert that fullWidthDebates reaches the feed. This viewport-height budget is the regression fix itself, so add a test that renders with fullWidth and verifies the bounded custom-property value (plus the claim-tab prop threading) rather than leaving the new path unprotected.
…e hero's layout
Two findings from Copilot's second pass, both in code it had not flagged before.
**The hero gave its second track back on a failed count.** `reserveVerdictColumn` keyed off
`summary.isLoading`, but a counts query that exhausts its retries leaves `total` at zero with
nothing loading any more — the shape of an unanswered claim, which is exactly what it is not, and
the distinction `hasCounts` exists in `claim-response-summary` to draw. So a failure re-wrapped
the title anyway, which is the shift the reserved track was added to stop, and a later successful
refetch would have wrapped it a second time. The track now follows `hasCounts`: unknown reserves,
only a measured zero releases.
This also settles a smaller inconsistency in the same expression. `hasCounts && total === 0` while
a background refetch was in flight used to re-reserve the column for a claim already known to have
no responses; it now stays released.
I checked every other read of `summary` in the hero for the same class — a failure read as a
settled negative. `hasVerdict` is correct: it draws no column on a failure, which is right, since
there is no verdict to draw. `summary.isControversial` is false on a failure too, so the
Controversial chip disappears — the same shape, but it comes from `summarizeClaimResponses` and
behaves identically on every Explore claim card, so it is neither this PR's doing nor obviously
wrong (a badge you cannot substantiate is better withheld). Reported, not changed.
**`fullWidth` had no test at all.** The viewport-height budget is last round's regression fix and
nothing in CI held it: no suite passed `fullWidth`, and none asserted the flag reaches the feed.
Three tests now do — the default 560px clamp, the container-bounded budget under `fullWidth`, and
`fullWidthDebates` threading through `ClaimRecordTab` on both tabs that can draw a debate.
Each fix was checked against the old behaviour: reverting any one of them fails its test.
Addressing the two Previously missed findings from the second Copilot pass (they came through in the review body rather than as inline threads, so replying here). Both were right. Fixed in eedcc54.
Preserve reserved track when counts remain unanswered — claim-page-view.tsx:147
Right, and fixed as suggested. Verified the premise against claim-response-summary.ts, whose own comment spells it out: "A counts query that exhausts its retries leaves data undefined, so total falls to zero while nothing is loading any more: the shape of an unanswered claim, which is exactly what it is not." That is precisely what hasCounts exists to distinguish, and I keyed the track off isLoading instead — so a terminal failure re-wrapped the title anyway, which is the shift the reserved track was added to stop.
reserveVerdictColumn is now !summary.hasCounts || summary.total > 0. Unknown reserves; only a measured zero releases.
Two things worth adding that the finding didn't mention:
It also fixes a second shift in the other direction. Under the old expression a later successful refetch would have re-reserved the column and wrapped the title a second time. Now the failure state and a subsequent success agree.
It fixes a smaller inconsistency in the same expression: hasCounts && total === 0 during a background refetch used to re-reserve the column for a claim already known to have no responses. It now stays released.
I swept every other read of summary in the hero for the same class — a failure read as a settled negative:
hasVerdict is correct. It draws no column on a failure, which is right: there is no verdict to draw. Only the track should survive the unknown, not its contents.
summary.isControversial is false on a failure too, so the Controversial chip disappears. Same shape, but it comes from summarizeClaimResponses and behaves identically on every Explore claim card — not this PR's doing, and not obviously wrong either, since a badge you cannot substantiate is better withheld. Reported, not changed.
Test: keeps the track when the counts fail rather than reading the failure as a zero. Verified it fails against the isLoading-keyed version.
Add tests for fullWidth layout and prop threading — debate-explore-feed-card.tsx:331
Right — there was no coverage at all.grep fullWidth across every test file in apps/web returned nothing, so the viewport-height budget that is last round's regression fix had nothing in CI holding it. Fair catch, and a fair thing to call the weakest point in the PR.
Three tests added:
caps itself at the card width, fitted to the viewport, by default — the 560px clamp on the default path.
raises the ceiling to the container under fullWidth without dropping the viewport budget — asserts both that max-w-[var(--debate-card-column-width)] is still applied and that the custom property is the container-bounded expression. That is exactly the regression: the old code dropped both together.
lets a debate fill the claim page column, on both tabs that can draw one in claim-record-tab.test.tsx — the fullWidthDebates threading.
Verified by reverting fullWidth to the drop-both version and removing the fullWidthDebates prop: both the layout test and the threading test fail.
One limitation worth stating plainly, since it is the reason the finding exists at all: jsdom computes no layout, so these tests pin the declaration, not the resulting pixel width. The widths themselves (800px at a 1300px viewport, 569px at 900px, 403px at 700px) were measured in a real browser and are in the PR description. What is still unverified is the two-video layout inside that column — a logged-out debate card falls back to the generic one, so that needs an authenticated session rather than another test.
useQueries starts every batch concurrently, so batching bounds each GraphQL document but not the number of requests. A pathological claim with hundreds of topics can still launch dozens of expensive three-count queries at once—the exact case this batching is intended to protect. Run batches through a bounded-concurrency scheduler (while preserving per-batch results), or paginate the topic list.
Documentation describes a removed readiness switch
This doc still describes a readiness switch in the header, but this component now renders only the position pills followed by ClaimEndSlot; the switch/header was removed. Update it to describe the debate offer under the pills so future changes are based on the actual UI.
Documentation incorrectly claims the list uses one request
This says the whole list uses one request, but the implementation below splits lists over 10 topics into multiple requests. Describe this as one request per batch (and usually one batch for current data) so the hook's documentation matches its behavior.
…ch changed
Copilot's third pass found two stale docstrings and no new behavioural findings. Both were real;
sweeping the branch for the same class turned up two more.
- `ClaimPositionSection` described "the readiness switch in the header's top right and the side
pills beneath". There is no switch and no header — it renders the pills, an error slot, and the
debate offer under them. The comment beside `ClaimEndSlot`, twenty lines below, already described
the actual arrangement and contradicted the docstring above it.
- `buildCountsSource` claimed "one request for the whole list". True when it was written; batching
arrived afterwards and made it one request per batch. Now says that, with the measured reason it
is still one batch in practice.
- `ClaimTopicsTab` named the three counts in the card's display order while describing the sum, and
said nothing about what happens when only some batches answer — which is the rule that decides
whether the list is ordered at all.
- `combineTopicConnectionCounts` said a failed count "leaves the metadata row out", which is now
true only of the cards whose own batch failed.
No behaviour changed, so nothing here is testable: these are comments, and a test asserting comment
text would pin the words rather than the thing they describe. The suites are unchanged and green.
Not done, and answered on the PR: bounding how many count requests `useQueries` starts at once.
The mechanism is real, but document size and request count cannot both be bounded while the input
is not — that is the same tension the previous round's finding sat on the other side of — and the
input is bounded in fact: across 3,000 claims sampled from testnet the largest carries 10 topics,
p99 is 4, and not one needs a second batch.
Third Copilot pass: Findings: None, plus a Previously missed (3). Again no inline threads, so replying here. Two were right and are fixed in b5d4bb1; one I'm declining, with the reasoning below.
❌ Unbounded concurrency launches all query batches simultaneously — use-topic-connection-counts.ts:186
Correct about the mechanism. Declining the fix — it would be worse than what it fixes.
useQueries does start every batch at once, so batching bounds each GraphQL document and not the number of requests. That part is accurate.
But note what this finding and the previous round's are asking for together. Round two flagged the request as unbounded in size and I batched it; this flags the result as unbounded in count. Those cannot both be bounded while the input is unbounded — capping the document size is exactly what creates more documents. The only real fix is bounding the input, i.e. capping how many topics a claim's Topics tab counts at all.
And the input is bounded in fact. I sampled 3,000 claims carrying topics off testnet:
topics on a claim
1
2
3
4
5
6
7
8
9
10
claims
770
1230
964
8
9
8
2
5
2
2
Max 10, p95 of 3, p99 of 4. Zero claims need a second batch. "Dozens of concurrent queries" needs a claim with 100+ topics; the largest in the graph has 10.
Each suggested fix costs something real for that:
A bounded-concurrency scheduler serialises batches. The tab blocks on all counts before it paints anything (deliberately — otherwise it paints the claim's order and resequences under the reader), so serialising makes the common case strictly slower for a case that does not occur.
Paginating the topic list breaks the feature. The list is ordered by total attachment count, which needs every count; you cannot rank page 1 correctly without page 2's numbers.
If a claim ever does carry hundreds of topics, the change I'd want is a cap on how many topics the tab counts and renders, with the remainder shown unranked — but that is a product decision about what a claim page should show, not a concurrency fix, and I'm not making it on a hypothetical. Flagged to @jwalkingjew rather than changed.
✅ Documentation describes a removed readiness switch — claim-page-view.tsx:463
Right, fixed.ClaimPositionSection renders the pills, an error slot, and ClaimEndSlot — no switch, no header. Worth adding that the comment beside ClaimEndSlot twenty lines below already described the real arrangement ("Under the pills rather than up in the hero"), so the file contradicted itself.
✅ Documentation incorrectly claims the list uses one request — use-topic-connection-counts.ts:52
Right, fixed. That paragraph predates the batching I added in response to round two's finding and I didn't revisit it. It now says one request per batch, with the measured reason it is still one batch in practice.
Swept the branch for the same class — a comment describing behaviour the code no longer has — and found two more, both mine, both fixed in the same commit:
ClaimTopicsTab's docstring named the three counts in the card's display order while describing the sum, and said nothing about the partial-failure rule that decides whether the list gets ordered at all.
combineTopicConnectionCounts said a failed count "leaves the metadata row out" — now true only of the cards whose own batch failed.
I checked the rest and they hold: the topic card's docstring still matches the shared-chrome rewrite, fullWidth's prop doc was corrected last round, and reserveVerdictColumn's comment was rewritten with the hasCounts fix.
On proof: these are comments, so there is no behaviour to break and no test to write. A test asserting comment text would pin the words rather than the thing they describe. The suites are unchanged — 174 files, 1,622 tests, green — and the three behavioural fixes from earlier rounds keep the tests that were each verified against the old behaviour.
Master was merged into the branch upstream, bringing the claim-page and
explore-card rework (#2530, #2535). No overlap with the search and facet
fixes here; the claims feed renders `ExploreFeedCard`, which that work
reshaped, so the suite and the live checks are re-run against it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #2526, which is closed in favour of this. Every commit from that PR is carried here with its original authorship (@o-p-o-p-o), rebased onto current
master; nothing of it is dropped. See From #2526 below for the two things that changed in the move.Two pieces of work, in order: the claim page's hero becomes the Explore claim card at page scale and topics move to their own tab (#2526), and that tab then becomes an explore feed of topic cards (this PR's own three commits).
The claim hero — from #2526
claim-card-narrow) it stacks as the card does — verdict below the pills, no rule. The hero is its own@container, so the switch follows the hero's width, not the window's. That is what matters in the side panel.SHOW_HERO_TOPICS = false).ClaimVerdictis deleted — the claim page was its only caller.ClaimSideRespondersgains a "6 people agree" title, profile-picture-first ordering and browse-sidebar hover, which apply wherever that popover opens.The Topics tab
That tab landed as the chip section, uncapped. A chip carries a name and nothing else, so a tab of them is a list with no way to tell which entry is worth opening — and it is the list the hero's "See all" deliberately sends people to.
Topics now render as an explore feed, ordered largest first by exactly the numbers the cards show.
useTopicLinkedEntitiesalready makes.The topic card
TopicExploreFeedCardis the generic explore card — the same frame, meta row, thumbnail well, title, two-line description and vote/comment row, now shared as components rather than copied. Everything new is one line between the description and the actions:ExploreFeedCard's type dispatch. The counts are a second request and the main feed pre-mounts cards thousands of pixels below the fold, so a Topic drawn anywhere else keeps the card it has today.The counts
useTopicConnectionCountsasksentitiesConnection, notrelationsConnection.relationsConnectionis faster and is what the topic page's composition strip uses — but it counts links, not things. The same claim carryingTopicsin two spaces is two relations: onAI governancethat is 552 relations against 505 claims, and 209 against 195 news stories. A card saying 552 beside a topic page saying 505 is a number nobody can reconcile, and here the number also orders the list.Debates were never a
Topicsrelation at all — a Debate carriesClaimsand neverTopics— so the link is two hops, debate → claim → topic. That is also exactly what "debates connected to the attached claims" means.Counts are unscoped to spaces, unlike the composition strip: a topic gathers across the whole graph, and narrowing to a viewer's allowlist would make a topic's size depend on who is reading and would reorder the tab the moment that allowlist resolved.
Batched at ten topics per request, and the parsed document is cached by batch size since the ids are variables. Across 3,000 claims sampled from testnet the largest carries 10 topics (p95 of 3, p99 of 4) and not one needs a second batch, so this is a single request in practice; the batching is what stops a pathological claim becoming one unbounded query, and what keeps a batch that fails from costing every other topic its numbers.
Reuse
The card and the tab are assembled from what already exists rather than reproduced:
explore-card-chrome.tsx(new) holds the card frame, the default body and the actions row, extracted fromexplore-feed-card. The body takes an optionalmetaslot — the whole of what a topic card adds. Its own module, not an export from the dispatcher, for the reasonExploreCardTitleis one: importing back fromexplore-feed-cardwould close a cycle the moment it ever draws a topic.META_SEGMENT_CLASSis exported fromexplore-meta-rowand imported, rather than the metadata line spelling the type out again. That file's own docstring records what happened last time a card reproduced this row by eye.PersonRecordFeeddraws the feed, as the Sources tab already does for the same shape of question (a bounded id list hydrated into explore cards). It gained an optionalrenderCard; the space lookup, item projection and the loading/empty/error/partial-failure states are unchanged and unduplicated. The key stays inside the feed.useClaimExploreRowshydrates the topic ids — the same hook Related claims, Debates and Sources use.useClaimExploreRowsandfetchExploreRowsByIdsbatch ids.ExploreFeedCommentLinkmoves to its own module so the shared actions row can draw it without a cycle. No behaviour change.RelationChipSection'scapprop is reverted. feat(claims): lay the claim page out like the Explore claim card, with a Topics tab #2526 added it so this tab could passInfinity; the tab no longer draws chips, and Subtopics — its only remaining caller — uses the default 8.From #2526
Two things changed in carrying it forward.
The Activity-header conflict is resolved in favour of what merged. #2526 took the Activity header from #2514's branch "as-is, so whichever of the two PRs merges second will have nothing to do in these files". #2514 has since merged, and the copy had drifted from it —
flexwhere master now hasflex-wrap, an arrow guard onnavigationwhere master usesselected.rows.length, plus a scroll fade #2514 dropped before merging.profile-activity-section.tsxand its test are now byte-identical tomaster, so that work is no longer duplicated here at all.Three review findings in the hero and the debate card are fixed.
The hero re-wrapped its own title on every load.
hasVerdictcannot be true until the response counts answer, and the grid template was derived from it, so every claim painted one-column and reflowed to1fr 220pxwhen the column appeared. The template now follows a separate question, keyed onhasCountsrather thanisLoading: unknown reserves the second track, only a measured zero releases it. A counts query that exhausts its retries reports zero with nothing loading — the shape of an unanswered claim, which is exactly what it is not — so keying offisLoadingwould have re-wrapped the title on failure, and again if a later refetch succeeded. The column's contents still wait for real numbers.An empty grid row sat above the claim. Row 1 belongs to the controversial/topics strip, which draws for almost nothing since
SHOW_HERO_TOPICSis off; with it empty the rows still started at 2, soclaim-card-narrow:gap-y-4left 16px above the claim belonging to a row nothing occupied. Measured 16px → 0px at 420px.A full-width debate stopped fitting the screen.
fullWidthdropped themax-wand the viewport-height budget, but it only ever meant "don't cap me at 560px, this column is already the reading width". On the 840px column that made a debate ~1140px tall, so on a 900px viewport the second debater and the interaction bar could not be seen together. It now raises the ceiling to the container rather than removing it:Checks
tsc --noEmitclean. ESLint and Prettier clean on every changed file.partials,core/claims,core/topics,core/explore,core/debates/browse: 174 files, 1,622 tests, all passing. Every fix for a review finding has a test that was checked against the old behaviour — reverting the fix fails the test.AI governance(706) →AI safety(122) →Model safety evaluation(12), and every count matches the API asked directly. The claim title measures 73px at first paint and 73px settled; the dead space above it at 420px is gone.fullWidthbudget is pinned by tests: the default 560px clamp, the container-bounded value underfullWidth, andfullWidthDebatesthreading throughClaimRecordTab.🤖 Generated with Claude Code