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