diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx
index cb82dbd4c..aeb0ad852 100644
--- a/packages/app/src/components/sidebar/project-leading-visual.tsx
+++ b/packages/app/src/components/sidebar/project-leading-visual.tsx
@@ -2,7 +2,6 @@ import { ActivityIndicator, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
-import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
@@ -195,10 +194,7 @@ function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle {
}
function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) {
- if (bucket !== "running") {
- return ;
- }
- return ;
+ return ;
}
function ProjectIcon({
@@ -231,10 +227,10 @@ function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | nu
return ;
}
-function getStatusDotColorStyle(
- bucket: Exclude,
-): ViewStyle {
- return bucket === "failed" ? styles.statusDotFailed : styles.statusDotAttention;
+function getStatusDotColorStyle(bucket: ProjectStatusBadgeDotBucket): ViewStyle {
+ if (bucket === "failed") return styles.statusDotFailed;
+ if (bucket === "running") return styles.statusDotRunning;
+ return styles.statusDotAttention;
}
const styles = StyleSheet.create((theme) => {
diff --git a/packages/app/src/components/sidebar/pulsing-status-dot.tsx b/packages/app/src/components/sidebar/pulsing-status-dot.tsx
deleted file mode 100644
index 826e9c19d..000000000
--- a/packages/app/src/components/sidebar/pulsing-status-dot.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useEffect } from "react";
-import type { StyleProp, ViewStyle } from "react-native";
-import Animated, {
- cancelAnimation,
- Easing,
- makeMutable,
- useAnimatedStyle,
- useReducedMotion,
- withRepeat,
- withTiming,
-} from "react-native-reanimated";
-import { useRetainedPanelActive } from "@/components/retained-panel";
-
-// The busy affordance for a sidebar row. Every status in the sidebar is a dot; busy is the
-// one that moves. Callers own the dot's geometry and color and pass it in as a style — this
-// module owns nothing but the pulse.
-//
-// Opacity only: the dot sits in a column of static dots in neighboring rows, so anything that
-// changes its size reads as jitter down the list. The floor stays clear of invisible — a dot
-// that blinks out reads as a glitch rather than as work in progress, and the row loses its
-// status marker on every cycle. One full cycle is out-and-back, so the visible period is
-// twice the half period.
-const PULSE_HALF_PERIOD_MS = 700;
-const PULSE_MIN_OPACITY = 0.45;
-
-// One clock for every pulsing dot in the app. Dots that each start their own animation drift
-// apart as rows mount at different times, and a column of them fading out of step is noise —
-// the same reason SyncedLoader drives all its instances from a single step. The value *is*
-// the opacity, so subscribers read it directly and there is nothing per-instance to fall out
-// of phase.
-//
-// Refcounted so the animation only runs while at least one dot is mounted: an unattended
-// withRepeat keeps the UI thread's frame loop awake forever.
-const pulseClock = makeMutable(1);
-let pulseClockSubscribers = 0;
-
-function acquirePulseClock(): void {
- pulseClockSubscribers += 1;
- if (pulseClockSubscribers > 1) {
- return;
- }
- pulseClock.value = withRepeat(
- withTiming(PULSE_MIN_OPACITY, {
- duration: PULSE_HALF_PERIOD_MS,
- easing: Easing.inOut(Easing.quad),
- }),
- -1,
- true,
- );
-}
-
-function releasePulseClock(): void {
- pulseClockSubscribers -= 1;
- if (pulseClockSubscribers > 0) {
- return;
- }
- cancelAnimation(pulseClock);
- pulseClock.value = 1;
-}
-
-/** A status dot, breathing in step with every other one on screen. */
-export function PulsingStatusDot({
- style,
- testID,
-}: {
- style: StyleProp;
- testID?: string;
-}) {
- const active = useRetainedPanelActive();
- const reduceMotion = useReducedMotion();
-
- useEffect(() => {
- if (!active || reduceMotion) {
- return;
- }
- acquirePulseClock();
- return releasePulseClock;
- }, [active, reduceMotion]);
-
- const pulseStyle = useAnimatedStyle(() => ({ opacity: pulseClock.value }));
-
- // Reduced motion leaves the dot fully opaque. It still carries the running color, and the
- // status is spelled out in the surrounding row's accessible label either way.
- return ;
-}
diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx
index a07ecc205..5fa30440d 100644
--- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx
+++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx
@@ -5,7 +5,6 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
-import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
import {
WorkspaceMetaRow,
type WorkspaceServiceSummary,
@@ -217,14 +216,14 @@ function WorkspaceStatusIndicator({
loading?: boolean;
reserveIdleSpace?: boolean;
}) {
- // Busy is a pulsing dot here for the same reason it is on a project icon: every status in
- // the sidebar is a dot, and a row with a project icon simply moves that dot onto the icon.
+ // Busy is a dot here for the same reason it is on a project icon: every status in the
+ // sidebar is a dot, and a row with a project icon simply moves that dot onto the icon.
// A row starting up and a row working are both busy, so they share the dot and differ only
// in testID.
if (loading) {
return (
-
+
);
}
@@ -232,7 +231,7 @@ function WorkspaceStatusIndicator({
if (shouldRenderSyncedStatusLoader({ bucket })) {
return (
-
+
);
}
diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts
index 398567d03..32cfea081 100644
--- a/packages/app/src/styles/theme.ts
+++ b/packages/app/src/styles/theme.ts
@@ -165,12 +165,11 @@ const darkStatusColors = {
// for them. Same four hues and the same generation rule as the status colors above, but its
// own band, because a dot is doing a different job than a check icon or a host badge.
//
-// A dot is 6-8pt of solid color with no shape to read and no label attached, and the running
-// one pulses down to PULSE_MIN_OPACITY for half of every cycle. At the status band's lightness
-// the dots read dimmer than the static text and icons beside them on the same row, which is
-// backwards — the dot is the row's state. So the band is pushed away from the surface rather
-// than toward it (darker in light, lighter in dark) and carries more chroma: 90% of gamut max
-// against the status family's 55-60%.
+// A dot is 6-8pt of solid color with no shape to read and no label attached. At the status
+// band's lightness the dots read dimmer than the static text and icons beside them on the same
+// row, which is backwards — the dot is the row's state. So the band is pushed away from the
+// surface rather than toward it (darker in light, lighter in dark) and carries more chroma:
+// 90% of gamut max against the status family's 55-60%.
//
// All four move together. A dot matching its siblings in lightness and chroma says only which
// state the row is in; one that does not says "this row matters more", which is a claim the