Design

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
Filip TsolovDesign engineering
Published
Reading time
13 min
TL;DR
  • 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.

Fig. 1 · interactive
100%
Six sessions in the sidebar

Which one is waiting on you?

Sessions6
Refactor billing webhooks
Fix flaky login test
Investigate slow search
Rename workspace settings
Upgrade Prisma
Add export to CSV
Six sessions moving through real states. Before: one wave for everything that runs, a dot for everything else. After: the indicator this post is about.
Fig. 1Six sessions moving through real states. Before: one wave for everything that runs, a dot for everything else. After: the indicator this post is about.

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.

Fig. 2
100%
0.150.300.450.000.030.300.150.300.45
The thinking icon, slowed down. The numbers are each dot’s phase: the fraction of a cycle by which it follows the first dot.
Fig. 2The thinking icon, slowed down. The numbers are each dot’s phase: the fraction of a cycle by which it follows the first 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.

Fig. 3 · interactive
100%
The same shapes at 14px

3x3: each dot is 3.2px wide. Below at 8x zoom; each grid square is one pixel.

X
Plus
Ring
Row
3x3 against 4x4 in the same 14px box. The large version is 8x zoom on the pixel grid, where each square is one pixel; the small glyph next to each name is the real size.
Fig. 33x3 against 4x4 in the same 14px box. The large version is 8x zoom on the pixel grid, where each square is one pixel; the small glyph next to each name is the real size.

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.

Fig. 4 · interactive
100%
Draw on nine dots

Click the dots. The small ones are what a sidebar row gets.

14px
28px
56px

A plus: half of “needs attention”.

Click dots to make a shape and see it at 14px. The pad names the shapes this post uses.
Fig. 4Click dots to make a shape and see it at 14px. The pad names the shapes this post uses.

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.

Fig. 5 · interactive
100%
The alphabet

Every state, live. Pick one to read why it looks the way it does.

Thinking, reading, searching, running commands. The wave we already had, unchanged.
Every state as it shipped. Pick one for the reasoning. The switch shows what people with reduced motion get: a still frame that keeps the shape and the colour.
Fig. 5Every state as it shipped. Pick one for the reasoning. The switch shows what people with reduced motion get: a still frame that keeps the shape and the colour.

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”.

CodeAgentIndicator.tsx (simplified)ts
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.
codeAgentIndicatorPatterns.ts (simplified)ts
// 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 },
},
Fig. 6 · interactive
100%
One keyframe, nine offsets

Working: “wave”, 850ms per cycle.

keyframedot · phase1 · 0.152 · 0.303 · 0.454 · 0.005 · 0.036 · 0.307 · 0.158 · 0.309 · 0.45
One keyframe curve on top, nine dot timelines below. Each timeline is the same curve shifted by the dot’s phase. Drag the scrubber to stop on a frame.
Fig. 6One keyframe curve on top, nine dot timelines below. Each timeline is the same curve shifted by the dot’s phase. Drag the scrubber to stop on a frame.

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.

animationClock.ts (simplified)ts
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.

Fig. 7 · interactive
100%
Twelve indicators, started at different moments

Each one runs from the moment it appeared.

The same indicators with and without the shared clock. With their own clocks, identical states drift apart; on the shared clock they move together.
Fig. 7The same indicators with and without the shared clock. With their own clocks, identical states drift apart; on the shared clock they move together.

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.

1sminimum time a state stays shown
200mscross-fade between two states
5keyframe sets for eight states
Fig. 8 · interactive
100%
True, but too fast

One sample turn. Top: what the agent reports. Bottom: what the indicator shows.

reportedwritingtestingwritingerrorattentionwritingdoneshownwritingtestingwritingerrorattentionwritingdone
38changes per minute, reported
28changes per minute, shown
The same turn twice. Move the dwell slider from 0 to 3000ms and watch the count of changes per minute. Switch the cross-fade off to see states cut.
Fig. 8The same turn twice. Move the dwell slider from 0 to 3000ms and watch the count of changes per minute. Switch the cross-fade off to see states cut.

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.

Design principleNo frame of a state may be indistinguishable from another state.
Fig. 9 · interactive
100%
Frames that look like idle

A red outline marks every moment a version shows the same picture as an idle session.

v1 · blinkA plus, one second on, one second off.
distinct
v2 · always litPlus and corners take turns.
distinct
v3 · tinted gridThe unlit dots carry the colour too.
distinct
IdleFor comparison: the grid, nothing lit.
reference
The versions side by side on one clock. A card gets a red outline whenever its current frame is the same picture as an idle session.
Fig. 9The versions side by side on one clock. A card gets a red outline whenever its current frame is the same picture as an idle session.
Fig. 10
100%

Needs attention, v1blink

Needs attention, v2always lit

Needs attention, v3tinted grid

Error, v1blink

Error, v3tinted grid

Eight frames across one cycle of each version. Outlined frames would pass for idle.
Fig. 10Eight frames across one cycle of each version. Outlined frames would pass for idle.

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.

Fig. 11 · interactive
100%
How big can a dot get?

3.20px dots in a 14px box (shipped). The gap between dots never changes.

X
Plus
Ring
Dot size in a 14px box, at 8x zoom on the pixel grid and at real size. The three buttons are the thinking icon’s size, the shipped size and the rejected one.
Fig. 11Dot size in a 14px box, at 8x zoom on the pixel grid and at real size. The three buttons are the thinking icon’s size, the shipped size and the rejected one.

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.

codeAgentIndicatorState.ts (simplified)ts
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.

Fig. 12 · interactive
100%
Session in, state out

The mapping is one pure function. Change the inputs.

Writing files

Running, and “editing” has its own pattern.

→ "writing"
The function above with its inputs exposed. Pick a status and an activity and see which state wins.
Fig. 12The function above with its inputs exposed. Pick a status and an activity and see which state wins.

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.

Fig. 13 · interactive
100%
Prototyped, not shipped

Three patterns that were built and left out.

Question

A hook that stays lit over a blinking dot. At nine dots it does not read as a question mark, so a question keeps the attention mark.

Pushing

A chevron that steps upwards. A push is usually done within a second, less than the minimum dwell.

Pulling

The same chevron, stepping downwards. Just as short-lived as a push.

The three prototypes, enlarged and at 14px. None of them is in the product.
Fig. 13The three prototypes, enlarged and at 14px. None of them is in the product.

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.

Fig. 14 · interactive
100%
Take the colour away

Every state has to stay distinct by shape and motion alone. The colour-blindness views are approximations.

Error
Needs attention
Using the computer
Running tests
Writing files
Working
Done
Idle
All states without colour, or through an approximate colour-blindness filter. Shape and motion have to do the work.
Fig. 14All states without colour, or through an approximate colour-blindness filter. Shape and motion have to do the work.

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

  1. Design every frame, not every state. “Off” frames are states too.
  2. Shape first, colour second. Colour reinforces meaning. It cannot carry it alone.
  3. Time is part of the design. Dwell and cross-fades turn true but noisy states into calm ones.
  4. Sync what sits side by side. One shared clock made many indicators feel like one system.
  5. Constraints pick the design. 14px chose 3x3, and 3x3 chose the shapes.
  6. Review in the medium that matches the sketch. Frame strips caught what code review could not.
  7. Draw before you decide. The notes said square and diamond. The sketch said plus and ring.
  8. Agents can build; taste still decides. The iterations that mattered were judgement calls.
  9. 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.

Fig. 15 · interactive
100%
Six sessions in the sidebar

Which one is waiting on you?

Sessions6
Refactor billing webhooks
Fix flaky login test
Investigate slow search
Rename workspace settings
Upgrade Prisma
Add export to CSV
The same six sessions. With “After” selected, the sessions waiting on you are the yellow ones.
Fig. 15The same six sessions. With “After” selected, the sessions waiting on you are the yellow ones.

Want to work on problems like this? We’re hiring