Designing a communicative agent status indicator
Every running agent in our sidebar showed the same wave. We turned its nine dots into a small status language, and learned that the frames where nothing is lit matter as much as the ones where something is.
- Author
- Published
- Reading time
- 13 min
- Every running agent in the Code sidebar showed the same wave. We turned its 3x3 grid into eight states you can tell apart at 14px.
- Shapes carry the meaning and colour reinforces it. Each pattern is one CSS keyframe plus a phase per dot.
- The rule we ended up with: no frame of a state may look like another state. That includes the frames where nothing is lit.
The question the sidebar couldn’t answer
I had several agents running in parallel. Every row in the sidebar showed the same wave. One of them had stopped to ask me something, and I could not tell which. To find it I clicked into each session in turn.
At that point “busy” was one animation, and everything else was a 6px dot. A session that was writing files, a session that was running tests and a session that was waiting on me all looked identical.
That set the challenge. The indicator had to say more, enough that you understand what is happening in a chat without opening it. And it had to say it with what was already there: nine dots in a 14px box. No label, no tooltip you have to hover, no extra icon. Figure 1 is that sidebar. Switch between before and after.
Starting material: a wave we already had
We already had the thinking icon: nine dots in a 3x3 grid and a 0.85 second wave that starts in the middle row and spreads outwards. Figure 2 shows it four times slower, next to the phase of each dot.
Cursor and Linear both use dot indicators, and those were our references. We took the idea of a dot matrix as a status language. We did not take the size: Linear’s grid is bigger and does not fit a 14px sidebar row.
The idea was to keep “working” exactly as it was and turn the grid into an alphabet. Nine dots that already meant “busy” could also say what kind of busy, and what happened after.
Constraints first: 14px, nine dots
The slot in a sidebar row is 14px. That decided most things before we drew anything.
The first question was whether to move to 4x4. More cells would allow more shapes. But at 14px a dot in a 4x4 grid is about 2px wide, and every shape we needed (an X, a plus, a ring, rows, a cursor) already reads at 3x3. More cells would have meant less legibility. Figure 3 shows both grids at real size and as the pixels a screen has to draw them with.
The second decision was that shapes carry the meaning, not colour. Colour is added on top. We come back to this at the end, because it is the part we are least sure about.
Nine dots are a small canvas. Figure 4 is a pad to try it: some shapes survive 14px and some turn into noise.
The first alphabet
The first version came from written notes. Attention was a yellow square. Writing filled the grid row by row. Error was a red X, done a diamond. Computer use was a wandering cursor, tests were rows, a push was a chevron, and a stale session had dimmed dots.
Then I drew the states on dot paper, and the sketch overruled the notes. This was the biggest surprise of the project. Written down, attention was a square and done was a diamond. Drawn, they became a plus and a ring. Words describe a state. Only a drawing shows whether nine dots can carry it. We followed the sketch.
Not every state survived. Push and pull finish too fast to be seen, and a state needs enough duration to be worth a shape. They are in figure 13, with one more idea that did not make it.
The process was short. I sketched on dot paper, a coding agent built it, and I reviewed the result as frame strips and in our dev playground. Frame strips were the review tool because they read like the sketch. The agent built things correctly. The calls I had to make were about feel: a blink that looked idle, dots that felt small, a grid that wanted a tint. The rest of this post is mostly those three.
Motion with one keyframe per idea
Every pattern is a CSS keyframe plus a phase for each dot. There are no JavaScript timers, so dozens of rows can animate cheaply. Five keyframe sets cover everything: wave, fill, row, cursor and blink.
A keyframe says how one dot behaves over a cycle. The phase says how much later each dot runs. The phase is applied as a negative animation delay, so a dot is already mid-cycle on the first frame and nothing has to “start up”.
function animatedDotStyle(dot, cycleMs) {
return {
"--code-indicator-peak": dot.opacity ?? 1,
animationDuration: cycleMs + "ms",
// Negative, so every dot is mid-cycle at its phase from the first frame.
animationDelay: ((dot.phase ?? 0) - 1) * cycleMs + "ms",
};
}The patterns are then only data:
- Writing. The phase follows reading order, so the grid fills like a paragraph and then clears.
- Tests. One row is lit for a third of the cycle, one second per row.
- Cursor. One dot is lit for a ninth of the cycle. Each cell gets a slot in a permutation, and no two consecutive steps are neighbours, so it clicks around instead of sweeping.
// Writing: the phase follows reading order, so the grid fills like a paragraph.
writing: {
dots: CELLS.map((cell) => ({ cell, phase: cell * 0.045 })),
motion: { keyframes: "fill", cycleMs: 3000 },
},
// Computer use: each cell gets one slot in a path without a run of neighbours.
const CURSOR_PATH = [4, 2, 6, 1, 7, 0, 5, 3, 8];
computer: {
dots: CURSOR_PATH.map((cell, step) => ({ cell, phase: step / 9 })),
motion: { keyframes: "cursor", cycleMs: 9 * 1500 },
},One clock for every indicator
The first build had a problem that no single indicator showed. A CSS animation starts when its element appears. Sessions start at different moments, so every row ran on its own clock. Six waves, each a little off from the next. Every one of them was correct, and together the sidebar felt chaotic.
The fix is a shared clock. When one of these animations starts, we set its start time to the origin of the document timeline instead of the moment the element mounted. Every indicator then computes its frame from the same zero. Two rows in the same state show the same frame, however late one of them appeared, and rows in different states still move on a common beat. It is one listener for the whole app, and it also covers the other loops that sit side by side, such as the shine on tool calls.
export function installAnimationClock() {
document.addEventListener("animationstart", (event) => {
if (!SHARED_CLOCK_ANIMATIONS.has(event.animationName)) return;
for (const animation of event.target.getAnimations()) {
if (animation instanceof CSSAnimation && animation.animationName === event.animationName) {
// The timeline origin, not the moment this element appeared.
animation.startTime = 0;
}
}
});
}It is a small change, and it makes a large difference. The sidebar stops looking like many things happening and starts looking like one thing with several parts. Figure 7 shows the same twelve indicators both ways.
Time is a design input
The states can be true and still feel hectic. An agent reads a file for 300ms between two edits. Shown faithfully, the indicator flickers from writing to working and back.
So a shown state stays for at least one second. A state that comes and goes within that hold never shows, and a short-lived state can lose to a newer one.
We chose one second by trying the neighbours. At 500ms it still flickered. At two seconds it felt laggy, as if the indicator were behind the agent. One second was where it felt calm but still current.
State changes also cross-fade over 200ms. One detail: the new layer is drawn underneath the old one, so the outgoing animation keeps its phase while it fades. It does not jump back to its first frame on the way out.
Never go dark
The first “needs attention” blinked: one second on, one second off. For half of the time it was an empty grid. An empty grid is exactly what idle looks like. The state that most needed to be seen spent half its life looking like the state that least did.
The first fix was to keep it lit. Attention now switches between a plus and the four corners and never turns off.
The second fix was to tint the unlit grid with the state’s colour. Done gets a blue centre over a softer blue grid. Attention gets a yellow wash.
The tint had a win we did not plan. Error still blinks, but its off phase is now a faint red grid and no longer a grey one. It stopped looking idle without any change to its animation.
Small changes that mattered
Bigger dots without moving anything. The dots went from 2.6px to 3.2px. The gap between them, the box and its centre stayed the same, so nothing shifted in the layout. 3.5px was rejected because the X and the plus merged into blobs.
Reduced motion. Every state has a still frame that keeps its shape and its colour. We kept the 200ms opacity cross-fade, because gentler should not mean abrupt. You can try the still frames with the switch in figure 5.
The logic behind the pixels
One pure function maps a session to a state. It is wrapped in a hook and well tested. Activity kinds it does not know, including ones the engine adds later, fall back to the working wave.
export function codeAgentIndicatorState(session): CodeAgentIndicatorState {
switch (session.status) {
case "running":
// Kinds without a pattern, including ones the engine adds later, keep the wave.
return RUNNING_ACTIVITY_STATE[session.activity?.kind] ?? "working";
case "attention":
if (session.failed) return "error";
return "attention";
case "review":
return session.sideChatWorking ? "working" : "done";
case "idle":
case "done":
return session.sideChatWorking ? "working" : "idle";
}
}The precedence is error, then attention, then activity. A side chat that is working never hides attention.
Ideas that have not made it yet
Three patterns were prototyped and left out. The engine already tells a question apart from an approval, so a question got its own mark: a hook that stays lit over a blinking dot. At nine dots it does not look enough like a question mark, so both still show the attention mark. Pushing and pulling got a chevron that steps up or down. They work as shapes, but a push is over in less than the one-second dwell, so the pattern would almost never be shown.
Open end: how much can colour carry?
Colour only marks the states that need you: yellow for “your turn”, red for an error, blue for done. The working states share one neutral colour and differ only by shape and motion. That is fine as long as shape alone really is enough. Figure 14 removes the colour so you can judge.
Three states are next, each with a sketch of how it would work:
- Stopped: a pause sign.
- Delegating: a ripple from the centre.
- Searching: one dot orbiting the ring.
Computer use already has its pattern, the cursor in figure 5. It waits on the engine reporting that activity.
Learnings
- Design every frame, not every state. “Off” frames are states too.
- Shape first, colour second. Colour reinforces meaning. It cannot carry it alone.
- Time is part of the design. Dwell and cross-fades turn true but noisy states into calm ones.
- Sync what sits side by side. One shared clock made many indicators feel like one system.
- Constraints pick the design. 14px chose 3x3, and 3x3 chose the shapes.
- Review in the medium that matches the sketch. Frame strips caught what code review could not.
- Draw before you decide. The notes said square and diamond. The sketch said plus and ring.
- Agents can build; taste still decides. The iterations that mattered were judgement calls.
- Accessibility labels drift unless they share the visuals’ logic.
The indicator has shipped. The early feedback was about delight: people like how it looks and feels, and the matrix reads as a signature. Delight is the early signal. Whether it makes the sidebar faster to scan is the next thing to watch.
Here is the sidebar from the start again.
Want to work on problems like this? We’re hiring