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
@@ -8,7 +8,10 @@ import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration } from "@/panels/panel-registry";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { StatusRing } from "@/components/status-ring";
import { getStatusRingOffset } from "@/components/status-ring/geometry";
import {
STATUS_INDICATOR_ALERT_SIZE,
STATUS_INDICATOR_DOT_SIZE,
@@ -112,6 +115,13 @@ interface WorkspaceTabIconProps {
active?: boolean;
size?: number;
statusDotBorderColor?: string;
/**
* The surface this icon is sitting on, so the running ring can knock out of it. This icon is
* shared by the desktop tab strip, the tab switcher trigger, the split drag chip and the
* subagents track, which are on different surfaces and some of which change surface on hover —
* it cannot work the colour out for itself.
*/
backdrop: SurfaceBackdrop;
}
const ThemedCheckIcon = withUnistyles(Check);
@@ -127,12 +137,13 @@ export function WorkspaceTabIcon({
active = false,
size = 14,
statusDotBorderColor,
backdrop,
}: WorkspaceTabIconProps): ReactElement {
const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
const bucket = presentation.statusBucket;
const isRunning = bucket === "running";
let statusDotColor: string | undefined;
if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
else if (bucket === "running") statusDotColor = styles.statusDotRunning.color;
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
const showNeedsInputAlert = bucket === "needs_input";
const Icon = presentation.icon;
@@ -154,13 +165,16 @@ export function WorkspaceTabIcon({
return (
<View style={agentIconWrapperStyle}>
<Icon size={size} color={iconColor} />
{statusDotColor ? (
{isRunning ? (
<View
style={statusDotStyle}
accessibilityRole={bucket === "running" ? "progressbar" : undefined}
accessibilityLabel={bucket === "running" ? "Agent running" : undefined}
/>
style={styles.statusRing}
accessibilityRole="progressbar"
accessibilityLabel="Agent running"
>
<StatusRing backdrop={backdrop} />
</View>
) : null}
{statusDotColor ? <View style={statusDotStyle} /> : null}
{showNeedsInputAlert ? (
<View style={styles.statusAlertOverlay}>
<ThemedCircleAlert size={STATUS_INDICATOR_ALERT_SIZE} uniProps={needsInputAlertMapping} />
@@ -186,13 +200,18 @@ export function WorkspaceTabOptionRow({
trailingAccessory,
}: WorkspaceTabOptionRowProps): ReactElement {
const { t } = useTranslation();
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionMainPressable,
(Boolean(hovered) || pressed || active) && styles.optionRowActive,
],
const isOptionActive = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) =>
Boolean(hovered) || pressed || active,
[active],
);
const pressableStyle = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionMainPressable,
isOptionActive(state) && styles.optionRowActive,
],
[isOptionActive],
);
const optionRowStyle = useMemo(
() => [styles.optionRow, active && styles.optionRowActive],
[active],
@@ -200,16 +219,27 @@ export function WorkspaceTabOptionRow({
return (
<View style={optionRowStyle}>
<Pressable onPress={onPress} style={pressableStyle}>
<View style={styles.optionLeadingSlot}>
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
</View>
<View style={styles.optionContent}>
<Text numberOfLines={1} style={styles.optionLabel}>
{presentation.titleState === "loading"
? t("workspace.tabs.loading")
: presentation.label}
</Text>
</View>
{(state) => {
const optionActive = isOptionActive(state);
return (
<>
<View style={styles.optionLeadingSlot}>
<WorkspaceTabIcon
presentation={presentation}
active={selected || active}
backdrop={optionActive ? "surface1" : "surface0"}
/>
</View>
<View style={styles.optionContent}>
<Text numberOfLines={1} style={styles.optionLabel}>
{presentation.titleState === "loading"
? t("workspace.tabs.loading")
: presentation.label}
</Text>
</View>
</>
);
}}
</Pressable>
{presentation.modified ? (
<View style={styles.optionModifiedDot} accessibilityLabel={t("workspace.tabs.modified")} />
@@ -241,6 +271,11 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
statusRing: {
position: "absolute",
right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
},
statusDotBorderDefault: {
borderColor: theme.colors.surface0,
},