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
@@ -4,7 +4,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
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 type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import {
WorkspaceMetaRow,
type WorkspaceServiceSummary,
@@ -19,12 +19,14 @@ import {
import { useAppSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { getStatusDotColor } from "@/utils/status-dot-color";
import {
STATUS_INDICATOR_ALERT_SIZE,
STATUS_INDICATOR_DOT_SIZE,
STATUS_INDICATOR_FILLED_DOT_SIZE,
} from "@/utils/status-indicator-geometry";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
@@ -32,11 +34,6 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba
const SCRIM_WIDTH = 48;
const SCRIM_SOLID_OFFSET = "55%";
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const needsInputColorMapping = (theme: Theme) => ({
color: theme.colors.surface0,
@@ -143,7 +140,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
leadingProjectIconDataUri?: string | null;
serviceSummary?: WorkspaceServiceSummary | null;
/** The row's current background, so the project status badge can knock out of it. */
backdrop: SurfaceBackdrop;
backdrop: SidebarSurfaceBackdrop;
isHovered: boolean;
isLoading: boolean;
isCreating?: boolean;
@@ -221,14 +218,14 @@ function WorkspaceStatusIndicator({
loading?: boolean;
reserveIdleSpace?: boolean;
}) {
// 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.
// Busy is the only status that moves, and it is the ring rather than a dot for the same
// reason it is a dot elsewhere: every status in the sidebar sits in this one slot, so busy
// has to fill it without displacing anything. A row starting up and a row working are both
// busy, so they share the ring and differ only in testID.
if (loading) {
return (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
<View style={styles.standaloneRunningDot} />
<StatusRing />
</View>
);
}
@@ -236,7 +233,7 @@ function WorkspaceStatusIndicator({
if (shouldRenderSyncedStatusLoader({ bucket })) {
return (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
<View style={styles.standaloneRunningDot} />
<StatusRing />
</View>
);
}
@@ -275,50 +272,16 @@ function WorkspaceStatusIndicator({
else KindIcon = ThemedFolder;
const dotColorStyle = getStatusDotColorStyle(bucket);
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
size={statusDotSize}
offset={statusDotOffset}
/>
) : null}
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
</View>
);
}
function StatusDotOverlay({
dotColorStyle,
size,
offset,
}: {
dotColorStyle: ViewStyle;
size: number;
offset: number;
}) {
const overlayStyle = useMemo(
() => [
styles.statusDotOverlay,
dotColorStyle,
{
width: size,
height: size,
right: offset,
bottom: offset,
},
],
[dotColorStyle, offset, size],
);
return <View style={overlayStyle} />;
function StatusDotOverlay({ dotColorStyle }: { dotColorStyle: ViewStyle }) {
return <View style={[styles.statusDotOverlay, dotColorStyle]} />;
}
function getStatusDotColorStyle(bucket: SidebarStateBucket) {
@@ -569,24 +532,22 @@ const styles = StyleSheet.create((theme) => ({
},
statusDotOverlay: {
position: "absolute",
right: 0,
bottom: 0,
width: STATUS_INDICATOR_DOT_SIZE,
height: STATUS_INDICATOR_DOT_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
standaloneStatusDot: {
width: STATUS_INDICATOR_DOT_SIZE,
height: STATUS_INDICATOR_DOT_SIZE,
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
},
standaloneRunningDot: {
width: STATUS_INDICATOR_DOT_SIZE,
height: STATUS_INDICATOR_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
},
idleStatusDot: {
width: 8,
height: 8,
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
opacity: 0.3,