feat(app): add running status ring and lighten status dots

Keep the animated ring phase-locked across hosts and derive its knockout surface from each host's actual interaction state. Align filled status circles at 6px while preserving the larger needs-input alert.

Co-authored-by: Christoph Leiter <christoph@leiter.io>
This commit is contained in:
Mohamed Boudra
2026-08-09 11:33:58 +02:00
co-authored by Christoph Leiter
parent 6ae6231d1f
commit c0daf8e066
23 changed files with 584 additions and 153 deletions
@@ -0,0 +1,58 @@
import { useLayoutEffect } from "react";
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
import { scheduleOnUI } from "react-native-worklets";
import { getStatusRingRotation } from "@/components/status-ring/geometry";
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
const activeRingCount = makeMutable(0);
const clockRunning = makeMutable(false);
function advanceSharedRotation(): void {
"worklet";
if (activeRingCount.value === 0) {
clockRunning.value = false;
return;
}
sharedRotation.value = getStatusRingRotation(Date.now());
requestAnimationFrame(advanceSharedRotation);
}
function registerStatusRing(registered: SharedValue<boolean>): void {
"worklet";
if (registered.value) {
return;
}
registered.value = true;
activeRingCount.value += 1;
if (!clockRunning.value) {
clockRunning.value = true;
sharedRotation.value = getStatusRingRotation(Date.now());
requestAnimationFrame(advanceSharedRotation);
}
}
function unregisterStatusRing(registered: SharedValue<boolean>): void {
"worklet";
if (!registered.value) {
return;
}
registered.value = false;
activeRingCount.value -= 1;
}
export function useStatusRingRotation(): SharedValue<number> {
const registered = useSharedValue(false);
useLayoutEffect(() => {
scheduleOnUI(registerStatusRing, registered);
return () => {
scheduleOnUI(unregisterStatusRing, registered);
};
}, [registered]);
return sharedRotation;
}
@@ -0,0 +1,35 @@
import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
import type { View as NativeView } from "react-native";
import { STATUS_RING_PERIOD_MS } from "@/components/status-ring/geometry";
const STATUS_RING_KEYFRAMES: PropertyIndexedKeyframes = {
transform: ["rotate(0deg)", "rotate(360deg)"],
};
const STATUS_RING_TIMING: KeyframeAnimationOptions = {
duration: STATUS_RING_PERIOD_MS,
easing: "linear",
iterations: Number.POSITIVE_INFINITY,
};
const STATUS_RING_TIMELINE_START_MS = 0;
export function useStatusRingAnimationRef(): RefCallback<NativeView> {
const arcElement = useRef<HTMLElement | null>(null);
const setArcElement = useCallback((instance: NativeView | null) => {
arcElement.current = instance instanceof HTMLElement ? instance : null;
}, []);
useLayoutEffect(() => {
const element = arcElement.current;
if (!element) {
return;
}
const animation = element.animate(STATUS_RING_KEYFRAMES, STATUS_RING_TIMING);
animation.startTime = STATUS_RING_TIMELINE_START_MS;
return () => {
animation.cancel();
};
}, []);
return setArcElement;
}
@@ -0,0 +1,121 @@
import type { ReactNode } from "react";
import { StyleSheet as RNStyleSheet, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { getStatusDotColor } from "@/utils/status-dot-color";
import {
STATUS_RING_FRAME_SIZE,
STATUS_RING_HEAD_OPACITY,
STATUS_RING_SIZE,
STATUS_RING_STROKE,
STATUS_RING_TRACK_OPACITY,
} from "@/components/status-ring/geometry";
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
export interface StatusRingProps {
// The surface the ring is drawn on top of, named rather than resolved, because theme colours
// are only legible inside `StyleSheet.create` — see docs/unistyles.md. The frame fills itself
// with this so the ring reads as a hole punched in whatever it overlaps: the agent icon under a
// tab dot, the project tile under a sidebar badge. Leave it out where the ring sits on flat
// background and has nothing to knock out.
backdrop?: SurfaceBackdrop | null;
}
/**
* The static half of the running indicator: the knockout, the track, and the centre dot. The
* platform entry points supply the rotating quarter and own only how it is driven.
*
* The ring has no colour prop. It only ever means one thing — an agent is running — so it reads
* that one colour out of the theme itself. Passing the colour in would materialise it at the call
* site, outside anything Unistyles tracks, and leave the mark in the old theme until something
* incidental re-rendered the row.
*/
export function StatusRingFrame({ backdrop, children }: StatusRingProps & { children: ReactNode }) {
return (
<View style={[styles.frame, getBackdropStyle(backdrop)]}>
<View style={styles.track} />
{children}
<View style={styles.centerDot} />
</View>
);
}
function getBackdropStyle(backdrop: SurfaceBackdrop | null | undefined) {
switch (backdrop) {
case "surface0":
return styles.backdropSurface0;
case "surface1":
return styles.backdropSurface1;
case "surfaceSidebar":
return styles.backdropSurfaceSidebar;
case "surfaceSidebarHover":
return styles.backdropSurfaceSidebarHover;
case "surface2":
return styles.backdropSurface2;
default:
return null;
}
}
// The rotating quarter's geometry, deliberately plain React Native rather than Unistyles: on
// native this style lands on a Reanimated `Animated.View`, and applying a theme-tracked style to
// one crashes when the theme changes — Unistyles and Reanimated both mutate the same native node.
// See docs/unistyles.md. The colour it needs lives on `styles.arc`, one level in, on a view
// Reanimated never touches.
export const rotatorStyles = RNStyleSheet.create({
rotator: {
position: "absolute",
width: STATUS_RING_SIZE,
height: STATUS_RING_SIZE,
},
});
export const styles = StyleSheet.create((theme) => {
const circle = {
width: STATUS_RING_SIZE,
height: STATUS_RING_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: STATUS_RING_STROKE,
} as const;
const runningColor = getStatusDotColor({ theme, bucket: "running" }) ?? undefined;
return {
frame: {
width: STATUS_RING_FRAME_SIZE,
height: STATUS_RING_FRAME_SIZE,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
backdropSurface0: { backgroundColor: theme.colors.surface0 },
backdropSurface1: { backgroundColor: theme.colors.surface1 },
backdropSurfaceSidebar: { backgroundColor: theme.colors.surfaceSidebar },
backdropSurfaceSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
backdropSurface2: { backgroundColor: theme.colors.surface2 },
// The closed ring the quarter runs on. Same colour rather than a grey so the indicator is one
// object at one hue; the opacity is what puts it behind the quarter instead of competing.
track: {
...circle,
position: "absolute",
borderColor: runningColor,
opacity: STATUS_RING_TRACK_OPACITY,
},
// The moving quarter. Three sides transparent leaves the top border alone, which on a fully
// rounded box is a 90° arc — the edge that makes the rotation legible against the track.
arc: {
...circle,
borderColor: "transparent",
borderTopColor: runningColor,
opacity: STATUS_RING_HEAD_OPACITY,
},
centerDot: {
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: runningColor,
},
};
});
@@ -0,0 +1,49 @@
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
// The running indicator is a status dot with a rotating ring around it, and the whole set of
// numbers below is a budget rather than four independent choices. The centre matches every other
// filled status circle; the gap and stroke grow outward from it without changing that shared size.
export const STATUS_RING_GAP = 1.5;
export const STATUS_RING_STROKE = 1.5;
// Derived outward from the dot, which means the dot is not a free variable: shrink it without
// opening the gap by the same amount and the ring closes in behind it, the whole mark scales
// down, and nothing about how the centre reads against the ring actually changes.
export const STATUS_RING_SIZE =
STATUS_INDICATOR_FILLED_DOT_SIZE + (STATUS_RING_GAP + STATUS_RING_STROKE) * 2;
// The knockout margin, and the reason the frame is wider than the ring. These indicators sit on
// the bottom-right corner of an agent or project icon, and a dot only stays readable there
// because it carries a ring of surface colour separating it from the art underneath. A ring drawn
// out to the frame's edge has no such separation and collides with the icon, so the frame is the
// knockout and the ring lives inside it.
export const STATUS_RING_HALO_INSET = 1;
export const STATUS_RING_FRAME_SIZE = STATUS_RING_SIZE + STATUS_RING_HALO_INSET * 2;
// The track and the quarter riding it are the same color and differ only here. The track is low
// enough that the quarter is unmistakably the thing moving, high enough that the ring still reads
// as a ring when the quarter is on the far side of it. The quarter is held off full so the centre
// dot stays the solid part of the mark — the ring is what the dot is doing, not a second dot.
export const STATUS_RING_TRACK_OPACITY = 0.3;
export const STATUS_RING_HEAD_OPACITY = 0.9;
// One period for every ring in the app. Each platform owns one mount-independent timeline: web
// animations share an absolute document-timeline start, while native publishes this wall-clock
// phase from one UI-thread clock. A row that appears mid-flight therefore lands in step with the
// rings already turning.
export const STATUS_RING_PERIOD_MS = 900;
export function getStatusRingRotation(nowMs: number): number {
"worklet";
return ((nowMs % STATUS_RING_PERIOD_MS) / STATUS_RING_PERIOD_MS) * 360;
}
/**
* Re-anchors a corner status dot's offset for the ring that replaces it.
*
* The dots this replaces are pinned to an icon's bottom-right corner rather than centred in a
* slot, so growing them in place would walk the indicator off the corner. Callers keep their
* existing dot offset and pass it through here to grow around the same centre point.
*/
export function getStatusRingOffset(dotOffset: number, dotSize: number): number {
return dotOffset - (STATUS_RING_FRAME_SIZE - dotSize) / 2;
}
@@ -0,0 +1,32 @@
import { memo } from "react";
import { View } from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import {
StatusRingFrame,
type StatusRingProps,
rotatorStyles,
styles,
} from "@/components/status-ring/frame";
import { useStatusRingRotation } from "@/components/status-ring/clock";
/**
* Native running indicator. The rotation is published by one shared UI-thread clock rather than
* per-instance timing, so a ring that mounts mid-flight is already in phase — see `clock.ts`.
*
* The rotated view carries no theme-tracked style; the coloured arc is nested inside it. Putting
* a Unistyles style on a Reanimated view crashes on theme change (docs/unistyles.md).
*/
export const StatusRing = memo(function StatusRing({ backdrop }: StatusRingProps) {
const rotation = useStatusRingRotation();
const rotatorStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<StatusRingFrame backdrop={backdrop}>
<Animated.View style={[rotatorStyles.rotator, rotatorStyle]}>
<View style={styles.arc} />
</Animated.View>
</StatusRingFrame>
);
});
@@ -0,0 +1,26 @@
import { memo } from "react";
import { View } from "react-native";
import {
StatusRingFrame,
type StatusRingProps,
rotatorStyles,
styles,
} from "@/components/status-ring/frame";
import { useStatusRingAnimationRef } from "@/components/status-ring/clock.web";
/**
* Web running indicator. A sidebar full of agents can hold dozens of these at once, so the browser
* drives the rotation from one absolute document-timeline epoch rather than from mount time — see
* `clock.web.ts`.
*/
export const StatusRing = memo(function StatusRing({ backdrop }: StatusRingProps) {
const rotatorRef = useStatusRingAnimationRef();
return (
<StatusRingFrame backdrop={backdrop}>
<View ref={rotatorRef} style={rotatorStyles.rotator}>
<View style={styles.arc} />
</View>
</StatusRingFrame>
);
});