Skip to content

Badge [2] in the Sidebar Isn't the Second Card You See

Adityo Guni Waluyo

Citation badges in the sidebar list are computed with indexOf from the original array, not the filtered iteration position, so numbers stay truthful.

TL;DR

While refactoring a chat sources sidebar, badge numbers were computed over a filtered array, so dropping knowledge entries shifted every web source's number and broke reference links. The fix: compute numbers with indexOf on the original turn.sources, so badges always match answer references. Lesson: reference numbers must come from original data, not filtered display order.

While reviewing a diff that refactored the sources list in the chat panel, my eye stopped at one pattern that looked trivial: badge numbers were computed from list iteration position. The list itself had just been filtered, knowledge entries dropped from the sidebar. I traced one click in my head: reference [2] in the answer would land on the wrong card.

Some context on that list's position. The center panel now holds the full list, including its expand state (since commit 1d35066). The sidebar only shows web sources, with knowledge entries filtered out through visibleSources, the result of filtering turn.sources with isKnowledgeSlug. Knowledge entries don't show up there at all. Looked done.

My first guess reading the diff: ah, this must be a DOM id problem. Wrong format, or swapped turn ids. But the card ids were actually designed right, with numbers from the original array. That's exactly where I stopped to think: if badge numbers were computed from the filtered list, every id would be wrong too, and no amount of formatting would help. The dangerous part wasn't the id's shape, but the number stuck inside it.

Badge [2] in the sidebar points to card 2 as seen on screen. Reference [2] in the answer means item 2 as seen in the data.

These two sentences differ, and that's the trap. Badge numbers were computed from map((src, i) => ...) over the filtered array. But filter doesn't just hide items; it builds a new array whose order is compact from zero again ([3]). The moment one knowledge entry gets dropped, every web source behind it moves up one position. The first web source that passes the filter gets number 1, even though [1] in the answer points to the knowledge entry that was just filtered out. Lose one card, and every number after it goes off.

Render position isn't the data index

react.dev puts it this way: keys let React know which array item matches which component, because an item's position in the list is its identity at render time ([1]). That matching is valid for the list being rendered. But the reference numbers in a chat answer are born earlier, from the original order of turn.sources, long before display comes into the picture. Two different worlds, and I accidentally conflated them.

One honest line

The fix is one line. Badge and card ids are now computed from the original array:

const visibleSources = turn.sources.filter(
  (src) => !isKnowledgeSlug(src.slug),
)

// per src in visibleSources — number from the ORIGINAL array:
const n = turn.sources.indexOf(src) + 1
// badge: {n}, card id: `ai-source-${turnId}-${n}`

indexOf returns the first position of an element in an array ([2]), so the number shown in the sidebar always matches the number the answer uses. The filter is free to change the display; it doesn't get a say in the numbering.

Honestly, there's a cleaner alternative performance-wise: carry the item-and-index pair from the start, map first into a { src, i } shape, then filter. No double lookup. But turn.sources only holds a handful of items, and slugs are unique per turn. If the list can ever contain duplicate elements someday, indexOf would hand the first index to all of them, and that's when I'd switch to explicit pairs. For now I pick the one line that's easiest to read.

One leftover from the rest of the refactor: since the expand behavior moved to the center panel, sidebar cards render without any open/close behavior. A click on a sidebar card now has one job, open the source directly. The sidebar becomes a purer list of references, the center panel the place to read. Two roles, two places, nothing doubled up anymore.

The rule I hold onto after this: if a number in the UI is used as a reference, that number has to come from the original data, not from a filtered array. Render position is just the order of pictures on screen; it can change with every filter, and that's exactly why it can't be borrowed as a reference number.

Now every time I run into map((src, i) over a filtered list, I pause for a second and ask one thing: what is this i an index of, and is anyone calling it "number three"?

Sources

Related articles