Skip to content
Back to the workshop

Open Before the Click

Intent is a signal, and a draft belongs to the person who typed it

E
EugeneBuilding Cleo
4 min read

Cleo has a small floating chat that follows you around the product. You can be in the content library or the calendar and ask a question without leaving the screen. When it opens, it should feel like stepping back into a conversation already under way. For a while it felt more like a waiting room.

The reason was ordinary. The bubble fetched the active conversation when it was clicked. Click, request, spinner, and then the last exchange appeared. A few hundred milliseconds on a good connection. Enough to make a continuation feel like a cold start.

Hover is a statement of intent

A pointer that settles on a control is telling you something. Most people hover over a thing a moment before they click it, and a keyboard user focuses it before they press it. That gap is short, but it is usually longer than the request the click is about to make.

So the bubble now fetches on intent. Hover or focus the collapsed avatar and the tail of the conversation loads in the background. By the time the click lands, the last exchange is already in place and opening the chat is a paint rather than a fetch.

Two details keep this honest. The prefetch is idempotent: a guard means a pointer drifting back and forth across the avatar does not send a request each time. And the prefetch reads only what the open would read anyway. If someone hovers and never clicks, the cost is one small read that would have been needed the next time they did.

The same idea applies one level up. The bubble has an expand control that takes you to the full conversation screen. Hovering that control now warms the full route, so moving from the small surface to the large one is immediate too. I deliberately left one route out: a fallback that works out "your latest conversation" on the server every time it is asked. Prefetching that on every hover would have re-run the lookup for nothing. Prefetch is only free when the thing being fetched is stable.

The draft is not the component's to lose

While I was in this code I found a quieter problem, and it mattered more.

The message input was uncontrolled. Its value lived in the DOM, which is a perfectly reasonable choice for a text box that needs to stay fast while someone types. The catch is that the DOM belongs to the component, and components unmount. Moving from the floating bubble to the full panel, starting a new conversation, a stage change that re-rendered the chat surface: each of these could remount the input, and each remount quietly threw away whatever had been typed.

Nobody reports this kind of bug precisely. People do not say "the draft was discarded on remount". They say "I typed something and it vanished", or they say nothing and type it again, slightly annoyed. It is the sort of small loss that wears away trust without ever producing an error.

The fix was to move the draft somewhere that does not unmount. It now lives in the app-wide store that already holds the floating chat's state, keyed by conversation. Both the full panel and the bubble take their starting value from it and write to it as the user types. The key matters. A draft scoped to its conversation cannot leak into a different one, and because conversation identifiers are unique per workspace, it cannot leak across workspaces either.

The rule I took from it is easy to state. Nothing that holds a person's words should depend on a component that can unmount. The words are theirs. The components are mine, and I rearrange them constantly.

Why these belong together

Prefetching and draft persistence look like separate fixes, one about speed and one about state. They answer the same question, which is what someone should find when they arrive somewhere. The answer should be exactly what they left, already there. A conversation that loads after the click and a message box that forgets what it held both tell the user the same thing: the product was not paying attention while they were away.

There is a limit to how far I took this. The fully continuous version, where the small chat grows into the large one with no route change at all, needs the conversation screen to become an overlay inside the app shell. That is a larger refactor and I have not done it yet. What exists now is the smoothest move two separate routes allow. The next screen is warm before you ask for it, and your words travel with you.

E

Written by Eugene

Building Cleo, an AI marketing operating system. These posts cover the architecture decisions, technical challenges, and lessons learned along the way.

More from the workshop