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:
co-authored by
Christoph Leiter
parent
6ae6231d1f
commit
c0daf8e066
@@ -7,6 +7,7 @@ import {
|
||||
} from "@getpaseo/protocol/agent-lifecycle";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
|
||||
|
||||
export function AgentStatusDot({
|
||||
status,
|
||||
@@ -56,8 +57,8 @@ function isAgentLifecycleStatus(value: string): value is AgentLifecycleStatus {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -376,18 +376,21 @@ function projectKebabStyle({
|
||||
|
||||
function getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -871,6 +874,7 @@ function ProjectHeaderRow({
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||
const localDaemonServerId = useLocalDaemonServerId();
|
||||
@@ -917,6 +921,17 @@ function ProjectHeaderRow({
|
||||
setContextMenuOpen(open);
|
||||
if (open) setIsHovered(false);
|
||||
}, []);
|
||||
const handleProjectPressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
interaction.handlePressIn(event);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleProjectPressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
interaction.handlePressOut();
|
||||
}, [interaction]);
|
||||
|
||||
const projectRowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
@@ -937,7 +952,7 @@ function ProjectHeaderRow({
|
||||
iconDataUri={iconDataUri}
|
||||
statusBucket={statusBucket}
|
||||
projectViewKey={project.viewKey}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
chevron={chevron}
|
||||
showChevron={isHovered && chevron !== null}
|
||||
isArchiving={isArchiving}
|
||||
@@ -982,10 +997,10 @@ function ProjectHeaderRow({
|
||||
<PressHighlight
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowHovered}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
highlightStyle={styles.projectRowPressed}
|
||||
onPressIn={handleProjectPressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPressOut={handleProjectPressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
@@ -1008,10 +1023,10 @@ function ProjectHeaderRow({
|
||||
enabledOnMobile={false}
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowHovered}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
highlightStyle={styles.projectRowPressed}
|
||||
onPressIn={handleProjectPressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPressOut={handleProjectPressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
@@ -1064,6 +1079,7 @@ function WorkspaceRowInner({
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const _isCompact = useIsCompactFormFactor();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
@@ -1083,6 +1099,17 @@ function WorkspaceRowInner({
|
||||
}
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
const handleWorkspacePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
interaction.handlePressIn(event);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleWorkspacePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
interaction.handlePressOut();
|
||||
}, [interaction]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
@@ -1093,6 +1120,7 @@ function WorkspaceRowInner({
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
const workspaceRowStyle = getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
});
|
||||
@@ -1127,10 +1155,10 @@ function WorkspaceRowInner({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowHovered}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handleWorkspacePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPressOut={handleWorkspacePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -1140,7 +1168,7 @@ function WorkspaceRowInner({
|
||||
leadingProjectName={leadingProjectName}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
|
||||
@@ -12,8 +12,13 @@ import {
|
||||
} from "@/utils/project-status-badge-content";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { STATUS_INDICATOR_ALERT_SIZE } from "@/utils/status-indicator-geometry";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import {
|
||||
STATUS_INDICATOR_ALERT_SIZE,
|
||||
STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
} from "@/utils/status-indicator-geometry";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { getStatusRingOffset } from "@/components/status-ring/geometry";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
|
||||
// Every surfaced status shares one badge shell, so the badge never changes size or position
|
||||
// between states. Only the thing inside it changes.
|
||||
@@ -29,7 +34,6 @@ const STATUS_BADGE_OFFSET = -4;
|
||||
//
|
||||
// The filled alert occupies the full badge shell so needs-input remains more prominent than
|
||||
// the passive status dots.
|
||||
const STATUS_BADGE_DOT_SIZE = 6;
|
||||
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
|
||||
// workspaceBranchText) so the icon centers on the title rather than floating above it.
|
||||
const LEADING_SLOT_HEIGHT = 20;
|
||||
@@ -65,7 +69,7 @@ export function ProjectLeadingVisual({
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
projectViewKey: string;
|
||||
/** The row's current background, so the status badge can knock out of it. */
|
||||
backdrop: SurfaceBackdrop;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
chevron?: "expand" | "collapse" | null;
|
||||
showChevron?: boolean;
|
||||
isArchiving?: boolean;
|
||||
@@ -116,7 +120,7 @@ export function ProjectStatusIndicator({
|
||||
projectViewKey: string;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
/** The row's current background, so the status badge can knock out of it. */
|
||||
backdrop: SurfaceBackdrop;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
loading?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
@@ -164,8 +168,23 @@ function ProjectStatusBadge({
|
||||
}: {
|
||||
content: ProjectStatusBadgeContent;
|
||||
statusBucket: SidebarStateBucket;
|
||||
backdrop: SurfaceBackdrop;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
}) {
|
||||
// Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout,
|
||||
// so nesting it inside would clip it against the very thing that was meant to separate it from
|
||||
// the icon. It anchors to the same corner instead, growing around the centre the dot had.
|
||||
if (content.kind === "dot" && content.bucket === "running") {
|
||||
return (
|
||||
<View
|
||||
role="status"
|
||||
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
|
||||
style={styles.statusRingAnchor}
|
||||
testID="project-status-badge"
|
||||
>
|
||||
<StatusRing backdrop={backdrop} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
role="status"
|
||||
@@ -182,14 +201,14 @@ function ProjectStatusBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle {
|
||||
function getStatusBadgeBackdropStyle(backdrop: SidebarSurfaceBackdrop): ViewStyle {
|
||||
switch (backdrop) {
|
||||
case "surfaceSidebar":
|
||||
return styles.statusBadgeOnSidebar;
|
||||
case "surfaceSidebarHover":
|
||||
return styles.statusBadgeOnSidebarHover;
|
||||
case "surface2":
|
||||
return styles.statusBadgeOnSurface2;
|
||||
default:
|
||||
return styles.statusBadgeOnSidebar;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,8 +259,8 @@ const styles = StyleSheet.create((theme) => {
|
||||
// so this badge can't drift from the status dots everywhere else.
|
||||
const statusDot = (bucket: ProjectStatusBadgeDotBucket) =>
|
||||
({
|
||||
width: STATUS_BADGE_DOT_SIZE,
|
||||
height: STATUS_BADGE_DOT_SIZE,
|
||||
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: getStatusDotColor({ theme, bucket }) ?? undefined,
|
||||
}) as const;
|
||||
@@ -282,6 +301,12 @@ const styles = StyleSheet.create((theme) => {
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
// Same corner as the shell, re-centred for the wider ring.
|
||||
statusRingAnchor: {
|
||||
position: "absolute",
|
||||
right: getStatusRingOffset(STATUS_BADGE_OFFSET, STATUS_BADGE_SIZE),
|
||||
bottom: getStatusRingOffset(STATUS_BADGE_OFFSET, STATUS_BADGE_SIZE),
|
||||
},
|
||||
statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar },
|
||||
statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
|
||||
statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
|
||||
/**
|
||||
* Which surface a sidebar row is currently painting, so anything knocking out of it — the status
|
||||
@@ -9,19 +9,19 @@ import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
* here rather than three ternaries beside three stylesheets: drifting from them is how the badge
|
||||
* ends up with a halo, and a halo in one grouping mode but not the other is worse than either.
|
||||
*
|
||||
* The pressed background is deliberately absent. It lives in a Pressable style callback and lasts
|
||||
* as long as a finger is down, which is not long enough to see a mismatched ring.
|
||||
*/
|
||||
export function getSidebarRowBackdrop({
|
||||
isDragging = false,
|
||||
isPressed = false,
|
||||
selected = false,
|
||||
isHovered = false,
|
||||
}: {
|
||||
isDragging?: boolean;
|
||||
isPressed?: boolean;
|
||||
selected?: boolean;
|
||||
isHovered?: boolean;
|
||||
}): SurfaceBackdrop {
|
||||
if (isDragging) return "surface2";
|
||||
}): SidebarSurfaceBackdrop {
|
||||
if (isDragging || isPressed) return "surface2";
|
||||
if (selected || isHovered) return "surfaceSidebarHover";
|
||||
return "surfaceSidebar";
|
||||
}
|
||||
|
||||
@@ -687,12 +687,15 @@ function StatusWorkspaceRowInner({
|
||||
inStatusGroup?: boolean;
|
||||
}) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const handlePressIn = useCallback(() => setIsPressed(true), []);
|
||||
const handlePressOut = useCallback(() => setIsPressed(false), []);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace}>
|
||||
@@ -713,6 +716,7 @@ function StatusWorkspaceRowInner({
|
||||
showShortcut,
|
||||
});
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
@@ -743,7 +747,9 @@ function StatusWorkspaceRowInner({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowHovered}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={onPress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -753,7 +759,7 @@ function StatusWorkspaceRowInner({
|
||||
leadingProjectName={projectName}
|
||||
leadingProjectIconDataUri={projectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={getSidebarRowBackdrop({ selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isPressed, selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -857,10 +863,12 @@ function StatusWorkspaceActionSlot({
|
||||
}
|
||||
|
||||
function getStatusWorkspaceRowStyle({
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
}: {
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
inStatusGroup: boolean;
|
||||
@@ -870,6 +878,7 @@ function getStatusWorkspaceRowStyle({
|
||||
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text } from "react-native";
|
||||
import { View, Text, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -264,6 +264,7 @@ function WorkspaceRowBody({
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowBodyProps) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const draggable = Boolean(drag);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
@@ -284,6 +285,17 @@ function WorkspaceRowBody({
|
||||
}
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
const handleWorkspacePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
if (draggable) interaction.handlePressIn(event);
|
||||
},
|
||||
[draggable, interaction],
|
||||
);
|
||||
const handleWorkspacePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
if (draggable) interaction.handlePressOut();
|
||||
}, [draggable, interaction]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
@@ -292,7 +304,12 @@ function WorkspaceRowBody({
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
|
||||
const workspaceRowStyle = getWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
});
|
||||
return (
|
||||
<View
|
||||
{...(draggable ? dragAttributes : {})}
|
||||
@@ -325,10 +342,10 @@ function WorkspaceRowBody({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowHovered}
|
||||
onPressIn={draggable ? interaction.handlePressIn : undefined}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handleWorkspacePressIn}
|
||||
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
|
||||
onPressOut={draggable ? interaction.handlePressOut : undefined}
|
||||
onPressOut={handleWorkspacePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -336,7 +353,7 @@ function WorkspaceRowBody({
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
@@ -458,18 +475,21 @@ function WorkspaceRowTrailingActions({
|
||||
|
||||
function getWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -495,6 +515,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
workspaceRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
workspaceRowDragging: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -710,7 +710,7 @@ function DragOverlayTabChipInner({
|
||||
|
||||
return (
|
||||
<View style={chipStyle}>
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} />
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
|
||||
<Text numberOfLines={1} style={chipLabelStyle}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user