Skip to content
Consultation

Chat Left, Results Right: Splitting One Overlay Into Two

Adityo Guni Waluyo

The source list and the chat fought over one overlay. Two panels with different z-index plus render-time visibility ended the fight.

The AI answer was streaming in, and the source article list, the little popup showing which blog posts fed the response, was overlapping the chat. I dragged the popup right to read the list. Then I needed to type a follow-up in the chat, dragged it back, and the source list vanished. One overlay, two surfaces, fighting over the same space every time either moved.

My first instinct: just bump the popup's z-index higher so it always wins. Problem solved, right? Not really, then the chat was permanently buried. Fine, I'd add a popupVisible boolean, an effect to sync it with the answer lifecycle, another effect to reset it when a new answer finished streaming. The kind of wiring that looks fine in a diff and breaks in three edge cases you didn't think about.

Two surfaces, two z-index layers

The fix was simpler and more structural: stop pretending one overlay could do both jobs. I split it into two independent panels. The chat sidebar stays docked on the left, full height, slides in from the left with a 200ms transition, sits at z-index 40 [3]. A results popup lives on the right, slides in from the right, sits at z-index 50 [3]. On wide screens they never overlap, plenty of room for both. On mobile, the higher z-index on the popup means it can cover the chat if the user pulls it up, which is the right behavior: you're reading sources, not typing.

The chat sidebar kept its existing logic: Sparkles icon to toggle open and closed, greeting chips when the thread is empty, ArrowUp button pinned to the bottom to send. None of that changed. The results popup got a few new elements: a small badge saying “Used in answer” with the citation number, article rows restyled to look like blog cards with formatted dates and tech icon tiles, and its own dismiss X button.

Derived state instead of another boolean

Here's where I went wrong in the first draft. I was about to create a popupVisible boolean, then write an effect to set it when an answer finished streaming, and another effect to flip it back when the user clicked X. That's the pattern React explicitly warns against: if you can calculate something during rendering, you don't need state or an Effect that updates it [1].

So I threw out the boolean. The popup's visibility is computed every render, not stored: popup shows only when there is an answered entry, analysis has finished, and the dismissal marker belongs to a different answer id. Three conditions. When I click X, I don't toggle a visibility flag. I set the dismissed marker to the current answer's ID. The derived check handles the rest: if a new answer arrives with a different ID, the dismissal no longer matches and the popup reappears automatically. No effect needed. No sync bug possible.

One value, one setter, and the render function figures out everything else from it.

The quiet part

The 200ms slide-in from left and right looks nice. But not everyone wants motion. The prefers-reduced-motion media query detects whether the user has asked their OS to minimize animations [2]. For those users, I added the left and right slide animation classes to a reduced-motion block that sets animation: none. No slide, no transition, the panel just appears. Small detail, but it's the kind of thing that makes the difference between “this works” and “this respects the person using it.”

The whole change was about one thing: surfaces that compete for the same space don't share a container. Give them their own layer, their own entry direction, their own z-index. And state you can derive during render is state you never need to write. If you want the thread that lives inside the chat panel, I wrote about turning the search dialog into an AI thread with one array.

Sources

Related articles